├── .envrc ├── clippy.toml ├── .versions └── shiori ├── bin ├── srr │ ├── .gitignore │ ├── src │ │ ├── webhook.rs │ │ └── config.rs │ ├── Readme.md │ └── Cargo.toml ├── shiori │ ├── i18n.toml │ ├── src │ │ ├── lib.rs │ │ ├── inspect │ │ │ ├── inspectors.rs │ │ │ └── inspectors │ │ │ │ ├── redirect.rs │ │ │ │ ├── hls.rs │ │ │ │ └── dash.rs │ │ ├── commands.rs │ │ ├── main.rs │ │ ├── i18n.rs │ │ └── commands │ │ │ ├── update.rs │ │ │ └── merge.rs │ ├── Readme.md │ ├── build.rs │ ├── i18n │ │ ├── zh-CN │ │ │ └── shiori.ftl │ │ └── en-US │ │ │ └── shiori.ftl │ ├── windows.manifest.xml │ └── Cargo.toml ├── ssadecrypt │ ├── Cargo.toml │ ├── build.rs │ ├── CHANGELOG.md │ └── src │ │ └── main.rs └── minyami │ ├── Cargo.toml │ └── build.rs ├── crates ├── ssa │ ├── tests │ │ ├── fixtures │ │ │ ├── .gitignore │ │ │ ├── ac3 │ │ │ │ ├── segment-0.ts │ │ │ │ ├── segment-0.ts.dec │ │ │ │ └── script.sh │ │ │ └── eac3 │ │ │ │ ├── segment-0.ts │ │ │ │ └── segment-0.ts.dec │ │ └── decrypt.rs │ ├── Readme.md │ ├── src │ │ ├── error.rs │ │ └── constant.rs │ ├── test │ │ └── fixtures │ │ │ └── eac3 │ │ │ └── script.sh │ ├── Cargo.toml │ ├── CHANGELOG.md │ └── benches │ │ └── decrypt.rs ├── iori │ ├── tests │ │ ├── downloader │ │ │ ├── mod.rs │ │ │ └── parallel.rs │ │ ├── fixtures │ │ │ ├── hls │ │ │ │ ├── rfc8216 │ │ │ │ │ ├── 8-1-simple-media-playlist.m3u8 │ │ │ │ │ ├── 8-2-live-media-playlist-using-https.m3u8 │ │ │ │ │ ├── 8-4-master-playlist.m3u8 │ │ │ │ │ ├── 8-3-playlist-with-encrypted-media-segments.m3u8 │ │ │ │ │ ├── 8-6-master-playlist-with-alternative-audio.m3u8 │ │ │ │ │ └── 8-7-master-playlist-with-alternative-video.m3u8 │ │ │ │ └── m3u8-rs │ │ │ │ │ ├── media-playlist-with-byterange.m3u8 │ │ │ │ │ ├── mediaplaylist-byterange.m3u8 │ │ │ │ │ └── Readme.md │ │ │ └── dash │ │ │ │ └── dash-mpd-rs │ │ │ │ └── dash-testcases-5b-1-thomson.mpd │ │ ├── dash │ │ │ ├── mod.rs │ │ │ └── dash_mpd_rs.rs │ │ ├── lib.rs │ │ ├── hls │ │ │ ├── mod.rs │ │ │ └── m3u8_rs.rs │ │ ├── streaming.rs │ │ └── source.rs │ ├── src │ │ ├── dash │ │ │ ├── mod.rs │ │ │ ├── segment.rs │ │ │ ├── url.rs │ │ │ ├── live │ │ │ │ └── selector.rs │ │ │ └── live.rs │ │ ├── download │ │ │ ├── mod.rs │ │ │ ├── sequencial.rs │ │ │ └── app.rs │ │ ├── hls │ │ │ ├── mod.rs │ │ │ ├── segment.rs │ │ │ ├── archive.rs │ │ │ ├── utils.rs │ │ │ └── live.rs │ │ ├── context.rs │ │ ├── merge │ │ │ ├── skip.rs │ │ │ └── concat.rs │ │ ├── util │ │ │ ├── range.rs │ │ │ ├── http.rs │ │ │ ├── mix.rs │ │ │ └── ordered_stream.rs │ │ ├── util.rs │ │ ├── raw │ │ │ ├── segments.rs │ │ │ └── mod.rs │ │ ├── error.rs │ │ ├── cache │ │ │ ├── file.rs │ │ │ └── opendal.rs │ │ ├── fetch.rs │ │ └── lib.rs │ ├── examples │ │ ├── pipe.rs │ │ └── dash_live.rs │ └── Cargo.toml ├── uri-match │ ├── Cargo.toml │ └── src │ │ └── error.rs ├── iori-ffmpeg │ ├── Cargo.toml │ ├── src │ │ └── error.rs │ └── build │ │ ├── build.rs │ │ ├── linux_ffmpeg.rs │ │ ├── macos_ffmpeg_cross.rs │ │ └── windows_ffmpeg_cross.rs └── iori-hls │ ├── Cargo.toml │ ├── src │ ├── error.rs │ ├── lib.rs │ └── parse.rs │ └── tests │ └── cases.rs ├── platforms ├── sheeta │ ├── src │ │ ├── lib.rs │ │ └── model.rs │ └── Cargo.toml ├── radiko │ ├── Readme.md │ ├── src │ │ ├── radiko_aSmartPhone7a.bin │ │ ├── lib.rs │ │ ├── error.rs │ │ ├── time.rs │ │ └── model.rs │ └── Cargo.toml ├── nicolive │ ├── src │ │ ├── lib.rs │ │ ├── proto │ │ │ └── dwango │ │ │ │ └── nicolive │ │ │ │ └── chat │ │ │ │ ├── data │ │ │ │ ├── atoms │ │ │ │ │ ├── sensitive.proto │ │ │ │ │ └── moderator.proto │ │ │ │ ├── origin.proto │ │ │ │ ├── state.proto │ │ │ │ └── message.proto │ │ │ │ └── edge │ │ │ │ └── payload.proto │ │ └── source.rs │ ├── build.rs │ └── Cargo.toml ├── showroom │ ├── Cargo.toml │ └── src │ │ └── constants.rs └── gigafile │ ├── Cargo.toml │ └── src │ └── lib.rs ├── .vscode └── settings.json ├── .cargo └── config.toml ├── plugins ├── plugin-example │ ├── Cargo.toml │ └── src │ │ └── lib.rs ├── plugin-sheeta │ ├── Cargo.toml │ └── src │ │ └── lib.rs ├── plugin-radiko │ └── Cargo.toml ├── plugin-showroom │ ├── Cargo.toml │ └── src │ │ └── lib.rs ├── plugin │ └── Cargo.toml ├── plugin-gigafile │ ├── Cargo.toml │ └── src │ │ └── lib.rs └── plugin-niconico │ └── Cargo.toml ├── .gitignore ├── README.md ├── scripts └── linker-wrapper.sh ├── flake.nix └── Cargo.toml /.envrc: -------------------------------------------------------------------------------- 1 | use flake -------------------------------------------------------------------------------- /clippy.toml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /.versions/shiori: -------------------------------------------------------------------------------- 1 | shiori-v0.3.0 -------------------------------------------------------------------------------- /bin/srr/.gitignore: -------------------------------------------------------------------------------- 1 | config.toml 2 | -------------------------------------------------------------------------------- /crates/ssa/tests/fixtures/.gitignore: -------------------------------------------------------------------------------- 1 | !*.ts -------------------------------------------------------------------------------- /crates/iori/tests/downloader/mod.rs: -------------------------------------------------------------------------------- 1 | mod parallel; 2 | -------------------------------------------------------------------------------- /platforms/sheeta/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod client; 2 | mod model; 3 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "rust-analyzer.check.command": "clippy" 3 | } -------------------------------------------------------------------------------- /.cargo/config.toml: -------------------------------------------------------------------------------- 1 | [target.x86_64-pc-windows-gnu] 2 | linker = "scripts/linker-wrapper.sh" 3 | -------------------------------------------------------------------------------- /bin/shiori/i18n.toml: -------------------------------------------------------------------------------- 1 | fallback_language = "en-US" 2 | 3 | [fluent] 4 | assets_dir = "i18n" 5 | -------------------------------------------------------------------------------- /crates/ssa/Readme.md: -------------------------------------------------------------------------------- 1 | ## iori-ssa 2 | 3 | Library to decrypt a Simple Sample-AES encrypted `MPEG-TS`. -------------------------------------------------------------------------------- /platforms/radiko/Readme.md: -------------------------------------------------------------------------------- 1 | # iori-radiko 2 | 3 | Migrated from https://github.com/garret1317/yt-dlp-rajiko. -------------------------------------------------------------------------------- /crates/iori/src/dash/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod live; 2 | pub mod segment; 3 | pub mod template; 4 | pub(crate) mod url; 5 | -------------------------------------------------------------------------------- /crates/ssa/tests/fixtures/ac3/segment-0.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iori-rs/iori/HEAD/crates/ssa/tests/fixtures/ac3/segment-0.ts -------------------------------------------------------------------------------- /crates/ssa/tests/fixtures/eac3/segment-0.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iori-rs/iori/HEAD/crates/ssa/tests/fixtures/eac3/segment-0.ts -------------------------------------------------------------------------------- /platforms/radiko/src/radiko_aSmartPhone7a.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iori-rs/iori/HEAD/platforms/radiko/src/radiko_aSmartPhone7a.bin -------------------------------------------------------------------------------- /crates/ssa/tests/fixtures/ac3/segment-0.ts.dec: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iori-rs/iori/HEAD/crates/ssa/tests/fixtures/ac3/segment-0.ts.dec -------------------------------------------------------------------------------- /crates/ssa/tests/fixtures/eac3/segment-0.ts.dec: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iori-rs/iori/HEAD/crates/ssa/tests/fixtures/eac3/segment-0.ts.dec -------------------------------------------------------------------------------- /platforms/nicolive/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod danmaku; 2 | pub mod model; 3 | pub mod program; 4 | pub mod source; 5 | pub mod watch; 6 | pub mod xml2ass; 7 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /bin/shiori/src/lib.rs: -------------------------------------------------------------------------------- 1 | mod app; 2 | pub mod commands; 3 | mod i18n; 4 | pub mod inspect; 5 | 6 | pub use app::ShioriApp; 7 | pub use shiori_plugin::async_trait; 8 | -------------------------------------------------------------------------------- /bin/shiori/src/inspect/inspectors.rs: -------------------------------------------------------------------------------- 1 | mod redirect; 2 | pub use redirect::ShortLinkPlugin; 3 | 4 | mod hls; 5 | pub use hls::HlsPlugin; 6 | 7 | mod dash; 8 | pub use dash::DashPlugin; 9 | -------------------------------------------------------------------------------- /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 | ``` -------------------------------------------------------------------------------- /crates/iori/src/download/mod.rs: -------------------------------------------------------------------------------- 1 | mod sequencial; 2 | pub use sequencial::SequencialDownloader; 3 | 4 | mod parallel; 5 | pub use parallel::{ParallelDownloader, spawn_ctrlc_handler}; 6 | 7 | mod app; 8 | pub use app::*; 9 | -------------------------------------------------------------------------------- /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 iori_hls; 10 | pub use source::*; 11 | -------------------------------------------------------------------------------- /bin/srr/src/webhook.rs: -------------------------------------------------------------------------------- 1 | use iori_showroom::model::RoomProfile; 2 | use serde::Serialize; 3 | 4 | #[derive(Serialize)] 5 | pub struct WebhookBody { 6 | pub event: &'static str, 7 | 8 | pub prefix: String, 9 | pub profile: RoomProfile, 10 | } 11 | -------------------------------------------------------------------------------- /platforms/radiko/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod auth; 2 | pub mod client; 3 | pub mod constants; 4 | pub mod error; 5 | pub mod model; 6 | pub mod time; 7 | 8 | pub use client::RadikoClient; 9 | pub use error::RadikoError; 10 | pub use model::*; 11 | pub use time::*; 12 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /plugins/plugin-example/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "shiori-plugin-example" 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 | shiori-plugin.workspace = true 11 | anyhow.workspace = true -------------------------------------------------------------------------------- /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/uri-match/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "uri-match" 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 | matchit = "0.8" 11 | url = "2.5" 12 | thiserror.workspace = true 13 | wildcard = "0.3.0" 14 | -------------------------------------------------------------------------------- /plugins/plugin-sheeta/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "shiori-plugin-sheeta" 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 | shiori-plugin.workspace = true 12 | iori-sheeta.workspace = true 13 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /plugins/plugin-radiko/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "shiori-plugin-radiko" 3 | version = "0.1.0" 4 | authors.workspace = true 5 | edition.workspace = true 6 | repository.workspace = true 7 | license.workspace = true 8 | 9 | [dependencies] 10 | shiori-plugin.workspace = true 11 | iori-radiko.workspace = true 12 | anyhow.workspace = true 13 | 14 | -------------------------------------------------------------------------------- /plugins/plugin-showroom/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "shiori-plugin-showroom" 3 | version = "0.1.0" 4 | authors.workspace = true 5 | edition.workspace = true 6 | repository.workspace = true 7 | license.workspace = true 8 | 9 | [dependencies] 10 | shiori-plugin.workspace = true 11 | iori-showroom.workspace = true 12 | anyhow.workspace = true 13 | -------------------------------------------------------------------------------- /plugins/plugin/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "shiori-plugin" 3 | version = "0.1.0" 4 | authors.workspace = true 5 | edition.workspace = true 6 | repository.workspace = true 7 | license.workspace = true 8 | 9 | [dependencies] 10 | anyhow.workspace = true 11 | async-trait = "0.1.86" 12 | iori.workspace = true 13 | regex.workspace = true 14 | serde.workspace = true 15 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /plugins/plugin-gigafile/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "shiori-plugin-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 | shiori-plugin.workspace = true 11 | iori-gigafile.workspace = true 12 | anyhow.workspace = true 13 | fake_user_agent.workspace = true -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /platforms/sheeta/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "iori-sheeta" 3 | version = "0.1.0" 4 | edition = "2024" 5 | 6 | [dependencies] 7 | anyhow.workspace = true 8 | reqwest.workspace = true 9 | tokio.workspace = true 10 | serde.workspace = true 11 | serde_json.workspace = true 12 | log.workspace = true 13 | fake_user_agent.workspace = true 14 | 15 | shiori-plugin.workspace = true 16 | regex.workspace = true 17 | -------------------------------------------------------------------------------- /.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 | tmp 16 | 17 | # Video files 18 | *.ts 19 | *.mkv 20 | *.mp4 21 | shiori_* 22 | 23 | # nix 24 | .direnv -------------------------------------------------------------------------------- /plugins/plugin-niconico/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "shiori-plugin-niconico" 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 | shiori-plugin.workspace = true 11 | anyhow.workspace = true 12 | tokio.workspace = true 13 | iori-nicolive.workspace = true 14 | 15 | chrono.workspace = true 16 | tracing.workspace = true -------------------------------------------------------------------------------- /crates/iori-ffmpeg/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "iori-ffmpeg" 3 | version = "0.0.1" 4 | edition.workspace = true 5 | authors.workspace = true 6 | repository.workspace = true 7 | license.workspace = true 8 | 9 | [dependencies] 10 | rsmpeg = "0.16.0" 11 | thiserror.workspace = true 12 | tokio.workspace = true 13 | tracing.workspace = true 14 | iori.workspace = true 15 | 16 | [features] 17 | default = [] 18 | link-system = ["rsmpeg/link_system_ffmpeg"] 19 | -------------------------------------------------------------------------------- /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 - -------------------------------------------------------------------------------- /platforms/showroom/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "iori-showroom" 3 | version = "0.1.0" 4 | authors.workspace = true 5 | edition.workspace = true 6 | repository.workspace = true 7 | license.workspace = true 8 | 9 | [dependencies] 10 | anyhow.workspace = true 11 | fake_user_agent.workspace = true 12 | reqwest.workspace = true 13 | serde.workspace = true 14 | serde_json.workspace = true 15 | 16 | [dev-dependencies] 17 | tokio = { workspace = true, features = ["full"] } 18 | -------------------------------------------------------------------------------- /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 - -------------------------------------------------------------------------------- /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/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 | tracing.workspace = true 15 | tracing-subscriber.workspace = true 16 | 17 | clap.workspace = true 18 | 19 | [[bin]] 20 | name = "minyami" 21 | path = "src/main.rs" 22 | -------------------------------------------------------------------------------- /crates/iori-hls/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "iori-hls" 3 | version = "0.0.1" 4 | 5 | edition.workspace = true 6 | authors.workspace = true 7 | repository.workspace = true 8 | license.workspace = true 9 | 10 | [dependencies] 11 | quick-m3u8 = "0.7.0" 12 | thiserror.workspace = true 13 | tracing.workspace = true 14 | 15 | # TODO: Remove this once quick-m3u8 is well tested and stable 16 | m3u8-rs = { git = "https://github.com/Yesterday17/m3u8-rs.git" } 17 | comparable = { version = "0.5.6", features = ["derive"] } 18 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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.workspace = true 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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /crates/iori/tests/dash/mod.rs: -------------------------------------------------------------------------------- 1 | mod dash_mpd_rs; 2 | mod r#static; 3 | 4 | use wiremock::{ 5 | Mock, MockServer, ResponseTemplate, 6 | matchers::{method, path}, 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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/src/context.rs: -------------------------------------------------------------------------------- 1 | use std::{path::PathBuf, sync::Arc}; 2 | 3 | use crate::HttpClient; 4 | 5 | #[derive(Clone)] 6 | pub struct IoriContext { 7 | pub client: HttpClient, 8 | pub shaka_packager_command: Arc>, 9 | 10 | pub manifest_retries: u32, 11 | pub segment_retries: u32, 12 | } 13 | 14 | impl Default for IoriContext { 15 | fn default() -> Self { 16 | Self { 17 | client: HttpClient::default(), 18 | shaka_packager_command: Arc::new(None), 19 | manifest_retries: 3, 20 | segment_retries: 5, 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /crates/iori-hls/src/error.rs: -------------------------------------------------------------------------------- 1 | #[derive(Debug, thiserror::Error)] 2 | pub enum M3u8ParseError { 3 | #[error("failed to read playlist line: {message}")] 4 | Reader { message: String }, 5 | #[error("invalid playlist: {0}")] 6 | InvalidPlaylist(String), 7 | } 8 | 9 | impl<'a> From> for M3u8ParseError { 10 | fn from(value: quick_m3u8::error::ReaderBytesError<'a>) -> Self { 11 | let line = String::from_utf8_lossy(value.errored_line).into_owned(); 12 | Self::Reader { 13 | message: format!("{}, line: {line}", value.error), 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /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.workspace = true 22 | chrono-tz = "0.10.3" 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 -------------------------------------------------------------------------------- /platforms/radiko/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "iori-radiko" 3 | version = "0.1.0" 4 | authors.workspace = true 5 | edition.workspace = true 6 | repository.workspace = true 7 | license.workspace = true 8 | 9 | [dependencies] 10 | anyhow.workspace = true 11 | base64.workspace = true 12 | chrono.workspace = true 13 | fake_user_agent.workspace = true 14 | quick-xml = { version = "0.38.3", features = ["serialize"] } 15 | rand.workspace = true 16 | regex.workspace = true 17 | reqwest.workspace = true 18 | serde.workspace = true 19 | serde_json.workspace = true 20 | thiserror.workspace = true 21 | url = { version = "2.5", features = ["serde"] } 22 | 23 | [dev-dependencies] 24 | tokio = { workspace = true, features = ["full"] } 25 | -------------------------------------------------------------------------------- /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-ffmpeg/src/error.rs: -------------------------------------------------------------------------------- 1 | use iori::IoriError; 2 | use thiserror::Error; 3 | 4 | #[derive(Debug, Error)] 5 | pub enum FfmpegError { 6 | #[error(transparent)] 7 | Ffmpeg(#[from] rsmpeg::error::RsmpegError), 8 | #[error(transparent)] 9 | Nul(#[from] std::ffi::NulError), 10 | #[error(transparent)] 11 | Join(#[from] tokio::task::JoinError), 12 | } 13 | 14 | impl From for IoriError { 15 | fn from(error: FfmpegError) -> Self { 16 | match error { 17 | FfmpegError::Ffmpeg(error) => IoriError::Custom(Box::new(error)), 18 | FfmpegError::Nul(error) => IoriError::Custom(Box::new(error)), 19 | FfmpegError::Join(error) => IoriError::Custom(Box::new(error)), 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /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 | pub webhook: Option, 9 | } 10 | 11 | #[derive(Serialize, Deserialize)] 12 | pub struct ShowroomConfig { 13 | pub rooms: Vec, 14 | } 15 | 16 | #[derive(Serialize, Deserialize, Clone)] 17 | pub struct WebhookConfig { 18 | pub url: String, 19 | } 20 | 21 | impl Config { 22 | pub fn load() -> anyhow::Result { 23 | let file = "config.toml"; 24 | let data = std::fs::read_to_string(file)?; 25 | let config = toml::from_str(&data)?; 26 | Ok(config) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /crates/iori/src/merge/skip.rs: -------------------------------------------------------------------------------- 1 | use super::Merger; 2 | use crate::{SegmentInfo, cache::CacheSource, error::IoriResult}; 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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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.5.1" 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.workspace = true 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 | -------------------------------------------------------------------------------- /crates/uri-match/src/error.rs: -------------------------------------------------------------------------------- 1 | use std::convert::Infallible; 2 | 3 | use matchit::InsertError; 4 | use thiserror::Error; 5 | use wildcard::WildcardError; 6 | 7 | pub type Result = std::result::Result; 8 | 9 | #[derive(Error, Debug)] 10 | pub enum UriHandlerError { 11 | #[error("Invalid scheme: {0}")] 12 | InvalidScheme(String), 13 | 14 | #[error("Invalid host pattern: {0}")] 15 | InvalidHostPattern(#[from] WildcardError), 16 | 17 | #[error("Invalid pattern: {0}")] 18 | InvalidPathPattern(String), 19 | 20 | #[error("Route insert error: {0}")] 21 | RouteInsertError(#[from] InsertError), 22 | 23 | #[error("Uri parse error: {0}")] 24 | UriParseError(#[from] url::ParseError), 25 | 26 | #[error("No matching route found for path: {0}")] 27 | NoMatchingPath(String), 28 | 29 | #[error(transparent)] 30 | Infallible(#[from] Infallible), 31 | } 32 | -------------------------------------------------------------------------------- /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`, `shiori-plugin-showroom`. 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 19 | - [ ] Support `EXT-X-DISCONTINUITY` for HLS 20 | - [ ] Support custom descryption logic 21 | - [ ] Support custom `StreamingSource` for plugins 22 | -------------------------------------------------------------------------------- /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/iori/src/util/range.rs: -------------------------------------------------------------------------------- 1 | #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] 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/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/iori-hls/tests/cases.rs: -------------------------------------------------------------------------------- 1 | use comparable::{Changed, assert_changes}; 2 | 3 | #[test] 4 | fn test_accuracy_radiko_01() { 5 | let data = include_bytes!("./fixtures/radiko_01.m3u8"); 6 | let old_result = iori_hls::m3u8_rs::parse_playlist_res(data); 7 | let new_result = iori_hls::parse::parse_playlist_res(data); 8 | 9 | let old_result = old_result.expect("Old parse engine should not error"); 10 | let new_result = new_result.expect("New parse engine should not error"); 11 | assert_eq!(old_result, new_result); 12 | } 13 | 14 | #[test] 15 | fn test_accuracy_archive_02() { 16 | let data = include_bytes!("./fixtures/archive_01.m3u8"); 17 | let old_result = iori_hls::m3u8_rs::parse_playlist_res(data); 18 | let new_result = iori_hls::parse::parse_playlist_res(data); 19 | 20 | let old_result = old_result.expect("Old parse engine should not error"); 21 | let new_result = new_result.expect("New parse engine should not error"); 22 | assert_changes!(old_result, new_result, Changed::Unchanged); 23 | } 24 | -------------------------------------------------------------------------------- /platforms/radiko/src/error.rs: -------------------------------------------------------------------------------- 1 | use thiserror::Error; 2 | 3 | #[derive(Error, Debug)] 4 | pub enum RadikoError { 5 | #[error("Network error: {0}")] 6 | Network(#[from] reqwest::Error), 7 | 8 | #[error("Authentication failed: {0}")] 9 | AuthFailed(String), 10 | 11 | #[error("Region mismatch: expected {expected}, got {actual}")] 12 | RegionMismatch { expected: String, actual: String }, 13 | 14 | #[error("Station not found: {0}")] 15 | StationNotFound(String), 16 | 17 | #[error("Program not available")] 18 | ProgramNotAvailable, 19 | 20 | #[error("Program not aired yet")] 21 | ProgramNotAiredYet, 22 | 23 | #[error("Program no longer available")] 24 | ProgramExpired, 25 | 26 | #[error("Timefree 30 subscription required")] 27 | TimeFree30Required, 28 | 29 | #[error("Parse error: {0}")] 30 | ParseError(String), 31 | 32 | #[error(transparent)] 33 | QuickXmlError(#[from] quick_xml::DeError), 34 | 35 | #[error(transparent)] 36 | Other(#[from] anyhow::Error), 37 | } 38 | -------------------------------------------------------------------------------- /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 variant = if cfg!(feature = "ffmpeg") { 16 | "ffmpeg" 17 | } else { 18 | "core" 19 | }; 20 | let hash = get_commit_hash().unwrap_or_else(|_| "unknown".to_string()); 21 | println!("cargo:rustc-env=SHIORI_VERSION={version} ({variant}-{hash})"); 22 | 23 | if let Ok("windows") = std::env::var("CARGO_CFG_TARGET_OS").as_deref() { 24 | let mut res = winresource::WindowsResource::new(); 25 | res.set_manifest_file("windows.manifest.xml"); 26 | res.compile().unwrap(); 27 | } else { 28 | // Avoid rerunning the build script every time. 29 | println!("cargo:rerun-if-changed=build.rs"); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /scripts/linker-wrapper.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # This is the truly definitive linker wrapper. It meticulously filters out any 3 | # problematic `-lstdc++` flags from rustc, and then appends a self-contained, 4 | # correctly-ordered static linking block to ensure all runtime libraries are linked statically. 5 | 6 | # Accumulate arguments into an array, filtering as we go. 7 | final_args=() 8 | for arg in "$@"; do 9 | # Filter out the standalone `-lstdc++` which rustc or its dependencies might add. 10 | if [ "$arg" != "-lstdc++" ]; then 11 | final_args+=("$arg") 12 | fi 13 | done 14 | 15 | # Now, execute the real g++ linker with the filtered arguments, 16 | # and append our definitive static linking block at the very end. 17 | # This version adds -static-libgcc and links pthread to resolve 18 | # the '__emutls_get_address' error, which is related to thread-local storage. 19 | # It also adds -lkernel32 to resolve GetThreadId. 20 | exec x86_64-w64-mingw32-g++ "${final_args[@]}" -static-libgcc -Wl,-Bstatic -lstdc++ -ldbghelp -lgcc_eh -l:libpthread.a -lmsvcrt -lmingwex -lmingw32 -lgcc -lmsvcrt -lmingwex -lkernel32 -Wl,-Bdynamic -------------------------------------------------------------------------------- /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/iori-ffmpeg/build/build.rs: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | #![allow(unused_attributes)] /* 3 | OUT=/tmp/tmp && rustc "$0" -o ${OUT} && exec ${OUT} $@ || exit $? #*/ 4 | 5 | use std::fs; 6 | use std::io::Result; 7 | use std::process::Command; 8 | 9 | fn main() -> Result<()> { 10 | let target = std::env::args() 11 | .nth(1) 12 | .unwrap_or_else(|| "x86_64-apple-darwin".to_string()); 13 | 14 | if fs::metadata("tmp").is_ok() { 15 | return Ok(()); 16 | } 17 | 18 | #[cfg(target_os = "linux")] 19 | { 20 | if target == "x86_64-pc-windows-gnu" { 21 | Command::new("./build/windows_ffmpeg_cross.rs").status()?; 22 | } else { 23 | Command::new("./build/linux_ffmpeg.rs").status()?; 24 | } 25 | } 26 | 27 | // Cross compile on macOS 28 | #[cfg(target_os = "macos")] 29 | { 30 | // FIXME: check current arch 31 | if target == "x86_64-apple-darwin" { 32 | Command::new("./build/macos_ffmpeg_cross.rs").status()?; 33 | } else { 34 | Command::new("./build/linux_ffmpeg.rs").status()?; 35 | } 36 | } 37 | 38 | Ok(()) 39 | } 40 | -------------------------------------------------------------------------------- /crates/ssa/benches/decrypt.rs: -------------------------------------------------------------------------------- 1 | use criterion::{Criterion, black_box, criterion_group, criterion_main}; 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/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/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(url, key.as_deref())?; 28 | let merger = PipeMerger::stdout(true); 29 | let cache = FileCacheSource::new(output_dir)?; 30 | 31 | ParallelDownloader::builder(Default::default()) 32 | .app(()) 33 | .cache(cache) 34 | .merger(merger) 35 | .concurrency(NonZeroU32::new(8).unwrap()) 36 | .retries(8) 37 | .ctrlc_handler() 38 | .download(source) 39 | .await?; 40 | 41 | Ok(()) 42 | } 43 | -------------------------------------------------------------------------------- /bin/shiori/src/commands.rs: -------------------------------------------------------------------------------- 1 | use clap::{ArgAction, Parser, Subcommand, builder::styling}; 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/main.rs: -------------------------------------------------------------------------------- 1 | use chrono::Timelike; 2 | use clap::Parser; 3 | use clap_handler::Handler; 4 | use shiori::commands::ShioriArgs; 5 | use std::fmt; 6 | use tracing::level_filters::LevelFilter; 7 | use tracing_subscriber::fmt::time::FormatTime; 8 | 9 | struct TimeOnly; 10 | 11 | impl FormatTime for TimeOnly { 12 | fn format_time(&self, w: &mut tracing_subscriber::fmt::format::Writer<'_>) -> fmt::Result { 13 | let now = chrono::Local::now(); 14 | 15 | let hours = now.hour(); 16 | let minutes = now.minute(); 17 | let seconds = now.second(); 18 | let millis = now.timestamp_millis() % 1000; 19 | 20 | write!( 21 | w, 22 | "{:02}:{:02}:{:02}.{:03}", 23 | hours, minutes, seconds, millis 24 | ) 25 | } 26 | } 27 | 28 | #[tokio::main] 29 | async fn main() -> anyhow::Result<()> { 30 | tracing_subscriber::fmt() 31 | .with_timer(TimeOnly) 32 | .with_target(false) 33 | .with_level(true) 34 | .with_env_filter( 35 | tracing_subscriber::EnvFilter::builder() 36 | .with_default_directive(LevelFilter::INFO.into()) 37 | .try_from_env() 38 | .unwrap_or_else(|_| "info,i18n_embed=off".into()), 39 | ) 40 | .with_writer(std::io::stderr) 41 | .init(); 42 | 43 | ShioriArgs::parse().run().await 44 | } 45 | -------------------------------------------------------------------------------- /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. -------------------------------------------------------------------------------- /bin/shiori/i18n/zh-CN/shiori.ftl: -------------------------------------------------------------------------------- 1 | shiori-about = 又一个直播下载器 2 | 3 | download-wait = 当未检测到直播流时,是否等待直播流开始 4 | download-experimental-ui = {"["}实验性功能] 启用文本图形界面(TUI) 5 | download-url = 视频地址 6 | 7 | download-http-headers = 设置 HTTP header,格式为 key: value 8 | download-http-cookies = 9 | {"["}高级选项] 设置 Cookie 10 | 11 | 当 headers 中有 Cookie 时,该选项不会生效。 12 | 如果你不知道这个字段要如何使用,请不要设置它。 13 | download-http-timeout = 下载超时时间,单位为秒 14 | download-http-http1-only = 强制使用 HTTP/1.1 进行 http 请求 15 | 16 | download-concurrency = 并发数 17 | download-segment-retries = 分块下载重试次数 18 | # download-segment-retry-delay = 设置下载失败后重试的延迟,单位为秒 19 | download-manifest-retries = manifest 下载重试次数 20 | 21 | download-cache-in-menory-cache = 使用内存缓存,下载时不将缓存写入磁盘 22 | download-cache-temp-dir = 23 | 临时目录 24 | 25 | 默认临时目录是当前目录或系统临时目录。 26 | 如果设置了 `cache_dir`,则此选项无效。 27 | download-cache-cache-dir = 28 | {"["}高级选项] 缓存目录 29 | 30 | 存储分块及下载时产生的临时文件的目录。 31 | 文件会直接存储在该目录下,而不会创建子目录。为安全起见,请自行创建子目录。 32 | download-cache-experimental-stream-dir-cache = 33 | {"["}实验性功能] 使用新版缓存目录结构 34 | 35 | 该结构支持断点续传,请搭配 `cache-dir` 使用。 36 | 37 | download-output-no-merge = 跳过合并 38 | download-output-concat = 使用 Concat 合并文件 39 | download-output-output = 输出文件名 40 | download-output-pipe = 输出到标准输出 41 | download-output-pipe-mux = 使用 FFmpeg 混流,仅在 `--pipe` 生效时有效 42 | download-output-pipe-to = 使用 Pipe 输出到指定路径 43 | download-output-experimental-proxy = {"["}实验性功能] 启动一个 HTTP Server 并提供 M3U8 给其他客户端使用 44 | download-output-no-recycle = 保留已下载的分片 45 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /crates/iori/tests/dash/dash_mpd_rs.rs: -------------------------------------------------------------------------------- 1 | use futures::StreamExt; 2 | use iori::{StreamingSource, context::IoriContext, dash::live::CommonDashLiveSource}; 3 | 4 | use crate::{AssertWrapper, dash::setup_mock_server}; 5 | 6 | #[tokio::test] 7 | async fn test_static_a2d_tv() -> anyhow::Result<()> { 8 | let data = include_str!("../fixtures/dash/dash-mpd-rs/a2d-tv.mpd"); 9 | let (playlist_uri, _server) = setup_mock_server(data).await; 10 | 11 | let context = IoriContext::default(); 12 | let playlist = CommonDashLiveSource::new(playlist_uri.parse()?, None)?; 13 | 14 | let mut stream = playlist.segments_stream(&context).await?; 15 | 16 | let segments_live = stream.next().await.assert_success()?; 17 | assert_eq!(segments_live.len(), 1896); 18 | // no further segments 19 | stream.next().await.assert_error(); 20 | 21 | Ok(()) 22 | } 23 | 24 | #[tokio::test] 25 | async fn test_dash_testcases_5b_1_thomson() -> anyhow::Result<()> { 26 | let data = include_str!("../fixtures/dash/dash-mpd-rs/dash-testcases-5b-1-thomson.mpd"); 27 | let (playlist_uri, _server) = setup_mock_server(data).await; 28 | 29 | let context = IoriContext::default(); 30 | let playlist = CommonDashLiveSource::new(playlist_uri.parse()?, None)?; 31 | 32 | let mut stream = playlist.segments_stream(&context).await?; 33 | 34 | let segments_live = stream.next().await.assert_success()?; 35 | assert_eq!(segments_live.len(), 248); 36 | // no further segments 37 | stream.next().await.assert_error(); 38 | 39 | Ok(()) 40 | } 41 | -------------------------------------------------------------------------------- /crates/iori/src/util.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(crate) type Unset = (); 12 | pub(crate) type Set = bool; 13 | 14 | pub async fn detect_manifest_type( 15 | url: &str, 16 | client: &HttpClient, 17 | ) -> IoriResult { 18 | // 1. chcek extension 19 | let url = reqwest::Url::parse(url)?; 20 | if url.path().to_lowercase().ends_with(".m3u8") { 21 | return Ok(true); 22 | } else if url.path().to_lowercase().ends_with(".mpd") { 23 | return Ok(false); 24 | } 25 | 26 | // 2. check content type 27 | let response = client.get(url).send().await?; 28 | let content_type = response 29 | .headers() 30 | .get("content-type") 31 | .and_then(|s| s.to_str().ok()) 32 | .map(|r| r.to_lowercase()); 33 | let initial_playlist_data = response.text().await.ok(); 34 | match content_type.as_deref() { 35 | Some("application/x-mpegurl" | "application/vnd.apple.mpegurl") => return Ok(true), 36 | Some("application/dash+xml") => return Ok(false), 37 | _ => {} 38 | } 39 | 40 | // 3. check by parsing 41 | if let Some(initial_playlist_data) = initial_playlist_data { 42 | let is_valid_m3u8 = iori_hls::parse_playlist_res(initial_playlist_data.as_bytes()).is_ok(); 43 | if is_valid_m3u8 { 44 | return Ok(is_valid_m3u8); 45 | } 46 | } 47 | 48 | Ok(false) 49 | } 50 | -------------------------------------------------------------------------------- /crates/iori/src/dash/segment.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | ByteRange, InitialSegment, RemoteStreamingSegment, SegmentFormat, StreamType, StreamingSegment, 3 | decrypt::IoriKey, 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: StreamType, 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 stream_type(&self) -> StreamType { 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/tests/hls/mod.rs: -------------------------------------------------------------------------------- 1 | mod m3u8_rs; 2 | mod rfc8216; 3 | 4 | use wiremock::{ 5 | Mock, MockServer, ResponseTemplate, 6 | matchers::{method, path}, 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 | -------------------------------------------------------------------------------- /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/gigafile/src/lib.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use fake_user_agent::get_chrome_rua; 3 | use reqwest::header::SET_COOKIE; 4 | use reqwest::{Client, Url}; 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: Url, 25 | ) -> Result<(String /* url */, String /* cookies */)> { 26 | let response = self.client.head(url.clone()).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 host = url.host_str().unwrap(); 43 | let file_id = url.path().strip_prefix('/').unwrap_or_else(|| url.path()); 44 | let mut download_url = format!("https://{host}/download.php?file={file_id}"); 45 | 46 | if let Some(key) = &self.key { 47 | download_url.push_str(&format!("&dlkey={}", key)); 48 | } 49 | 50 | Ok((download_url, cookie)) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /crates/iori-hls/src/lib.rs: -------------------------------------------------------------------------------- 1 | mod error; 2 | #[doc(hidden)] 3 | pub mod m3u8_rs; 4 | mod models; 5 | #[doc(hidden)] 6 | pub mod parse; 7 | 8 | pub use error::M3u8ParseError; 9 | pub use models::*; 10 | 11 | pub fn parse_playlist_res(input: &[u8]) -> Result { 12 | let m3u8_rs_result = m3u8_rs::parse_playlist_res(input); 13 | let quick_m3u8_result = parse::parse_playlist_res(input); 14 | 15 | match (&m3u8_rs_result, &quick_m3u8_result) { 16 | (Ok(m3u8_rs_playlist), Ok(quick_m3u8_playlist)) => { 17 | if m3u8_rs_playlist != quick_m3u8_playlist { 18 | tracing::warn!( 19 | "New m3u8 parse engine produced different result, this should not happen.\nold: {:?}\nnew: {:?}\nRaw input: {}", 20 | m3u8_rs_playlist, 21 | quick_m3u8_playlist, 22 | String::from_utf8_lossy(input) 23 | ); 24 | } 25 | } 26 | (Ok(_), Err(quick_m3u8_error)) => { 27 | tracing::warn!( 28 | "New m3u8 parse engine produced an error, but the old one passed.\nError: {quick_m3u8_error}\nRaw input: {}", 29 | String::from_utf8_lossy(input) 30 | ); 31 | } 32 | (Err(m3u8_rs_error), Ok(_)) => { 33 | tracing::warn!( 34 | "Old m3u8 parse engine produced an error, but the new one passed.\nError: {m3u8_rs_error}\nRaw input: {}", 35 | String::from_utf8_lossy(input) 36 | ); 37 | } 38 | _ => { 39 | // both errored, treat as normal 40 | } 41 | } 42 | 43 | // Always return the m3u8-rs result 44 | m3u8_rs_result 45 | } 46 | -------------------------------------------------------------------------------- /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 | pub use reqwest; 7 | 8 | #[derive(Clone)] 9 | pub struct HttpClient { 10 | client: Client, 11 | cookies_store: Arc, 12 | } 13 | 14 | impl HttpClient { 15 | pub fn new(builder: ClientBuilder) -> Self { 16 | let cookies_store = Arc::new(CookieStoreMutex::new(CookieStore::default())); 17 | let client = builder 18 | .cookie_provider(cookies_store.clone()) 19 | .build() 20 | .unwrap(); 21 | 22 | Self { 23 | client, 24 | cookies_store, 25 | } 26 | } 27 | 28 | pub fn add_cookies(&self, cookies: Vec, url: impl IntoUrl) { 29 | if cookies.is_empty() { 30 | return; 31 | } 32 | 33 | let url = url.into_url().unwrap(); 34 | let mut lock = self.cookies_store.lock().unwrap(); 35 | for cookie in cookies { 36 | _ = lock.parse(&cookie, &url); 37 | } 38 | } 39 | } 40 | 41 | impl Default for HttpClient { 42 | fn default() -> Self { 43 | let cookies_store = Arc::new(CookieStoreMutex::new(CookieStore::default())); 44 | let client = Client::builder() 45 | .cookie_provider(cookies_store.clone()) 46 | .build() 47 | .unwrap(); 48 | 49 | Self { 50 | client, 51 | cookies_store, 52 | } 53 | } 54 | } 55 | 56 | impl Deref for HttpClient { 57 | type Target = Client; 58 | 59 | fn deref(&self) -> &Self::Target { 60 | &self.client 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /plugins/plugin-example/src/lib.rs: -------------------------------------------------------------------------------- 1 | use shiori_plugin::*; 2 | 3 | pub struct ExamplePlugin; 4 | 5 | impl ShioriPlugin for ExamplePlugin { 6 | fn name(&self) -> Cow<'static, str> { 7 | "example".into() 8 | } 9 | 10 | fn version(&self) -> Cow<'static, str> { 11 | "0.1.0".into() 12 | } 13 | 14 | fn description(&self) -> Option> { 15 | Some("Extracts Showroom playlists from the given URL.".into()) 16 | } 17 | 18 | fn arguments(&self, command: &mut dyn InspectorCommand) { 19 | command.add_argument( 20 | "example-arg", 21 | Some("example_arg"), 22 | "[Example] Your example argument.", 23 | ); 24 | } 25 | 26 | fn register(&self, registry: &mut dyn InspectorRegistry) -> anyhow::Result<()> { 27 | registry.register_inspector( 28 | Regex::new(r"https://example.com/(?.*)").unwrap(), 29 | Box::new(ExampleInspector), 30 | PriorityHint::Normal, 31 | ); 32 | 33 | Ok(()) 34 | } 35 | } 36 | 37 | struct ExampleInspector; 38 | 39 | #[async_trait] 40 | impl Inspect for ExampleInspector { 41 | fn name(&self) -> Cow<'static, str> { 42 | "example".into() 43 | } 44 | 45 | async fn inspect( 46 | &self, 47 | _url: &str, 48 | _captures: &Captures, 49 | _args: &dyn InspectorArguments, 50 | ) -> anyhow::Result { 51 | Ok(InspectResult::Playlist(InspectPlaylist { 52 | title: Some("Example Playlist".to_string()), 53 | playlist_url: "https://example.com/playlist.m3u8".to_string(), 54 | playlist_type: PlaylistType::HLS, 55 | ..Default::default() 56 | })) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /crates/iori/src/hls/segment.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | ByteRange, InitialSegment, RemoteStreamingSegment, SegmentFormat, StreamType, StreamingSegment, 3 | decrypt::IoriKey, 4 | }; 5 | use std::sync::Arc; 6 | 7 | #[derive(Debug, Clone)] 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 | pub stream_type: Option, 20 | 21 | /// Sequence id allocated by the downloader, starts from 0 22 | pub sequence: u64, 23 | /// Media sequence id from the m3u8 file 24 | pub media_sequence: u64, 25 | 26 | pub duration: f64, 27 | pub format: SegmentFormat, 28 | } 29 | 30 | impl StreamingSegment for M3u8Segment { 31 | fn stream_id(&self) -> u64 { 32 | self.stream_id 33 | } 34 | 35 | fn sequence(&self) -> u64 { 36 | self.sequence 37 | } 38 | 39 | fn file_name(&self) -> &str { 40 | self.filename.as_str() 41 | } 42 | 43 | fn initial_segment(&self) -> InitialSegment { 44 | self.initial_segment.clone() 45 | } 46 | 47 | fn key(&self) -> Option> { 48 | self.key.clone() 49 | } 50 | 51 | fn duration(&self) -> Option { 52 | Some(self.duration) 53 | } 54 | 55 | fn stream_type(&self) -> StreamType { 56 | self.stream_type.unwrap_or(StreamType::Video) 57 | } 58 | 59 | fn format(&self) -> SegmentFormat { 60 | self.format.clone() 61 | } 62 | } 63 | 64 | impl RemoteStreamingSegment for M3u8Segment { 65 | fn url(&self) -> reqwest::Url { 66 | self.url.clone() 67 | } 68 | 69 | fn byte_range(&self) -> Option { 70 | self.byte_range.clone() 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /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 total_len = self.iter().map(|v| v.len()).sum(); 14 | let mut result = Vec::with_capacity(total_len); 15 | 16 | let mut iters: Vec<_> = self 17 | .into_iter() 18 | .map(|v| v.into_iter()) 19 | .filter(|iter| iter.len() > 0) 20 | .collect(); 21 | 22 | while !iters.is_empty() { 23 | iters.retain_mut(|iter| { 24 | if let Some(item) = iter.next() { 25 | result.push(item); 26 | true 27 | } else { 28 | false 29 | } 30 | }); 31 | } 32 | result 33 | } 34 | } 35 | 36 | #[cfg(test)] 37 | mod tests { 38 | use super::*; 39 | 40 | #[test] 41 | fn test_mix_single_vec() { 42 | let mixed_vec = vec![vec![1, 3, 5]].mix(); 43 | assert_eq!(mixed_vec, vec![1, 3, 5]); 44 | } 45 | 46 | #[test] 47 | fn test_mix_vec() { 48 | let mixed_vec = vec![vec![1, 3, 5], vec![2, 4, 6]].mix(); 49 | assert_eq!(mixed_vec, vec![1, 2, 3, 4, 5, 6]); 50 | } 51 | 52 | #[test] 53 | fn test_mix_vec_empty() { 54 | let mixed_vec = vec![vec![], vec![1, 2, 3]].mix(); 55 | assert_eq!(mixed_vec, vec![1, 2, 3]); 56 | } 57 | 58 | #[test] 59 | fn test_mix_vec_different_length() { 60 | let mixed_vec: Vec = vec![vec![1, 2, 3, 4, 5, 6], vec![7, 8, 9]].mix(); 61 | assert_eq!(mixed_vec, vec![1, 7, 2, 8, 3, 9, 4, 5, 6]); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /crates/iori/tests/downloader/parallel.rs: -------------------------------------------------------------------------------- 1 | use std::sync::{Arc, atomic::AtomicU8}; 2 | 3 | use iori::{ 4 | cache::memory::MemoryCacheSource, 5 | download::{ParallelDownloader, TracingApp}, 6 | merge::SkipMerger, 7 | }; 8 | 9 | use crate::source::{TestSegment, TestSource}; 10 | 11 | #[tokio::test] 12 | async fn test_parallel_downloader_with_failed_retry() -> anyhow::Result<()> { 13 | let source = TestSource::new(vec![TestSegment { 14 | stream_id: 1, 15 | sequence: 1, 16 | file_name: "test.ts".to_string(), 17 | fail_count: Arc::new(AtomicU8::new(2)), 18 | }]); 19 | 20 | let cache = Arc::new(MemoryCacheSource::new()); 21 | 22 | ParallelDownloader::builder(Default::default()) 23 | .app(TracingApp::default()) 24 | .merger(SkipMerger) 25 | .cache(cache.clone()) 26 | .retries(1) 27 | .ctrlc_handler() 28 | .download(source) 29 | .await?; 30 | 31 | let result = cache.into_inner(); 32 | let result = result.lock().unwrap(); 33 | assert_eq!(result.len(), 0); 34 | 35 | Ok(()) 36 | } 37 | 38 | #[tokio::test] 39 | async fn test_parallel_downloader_with_success_retry() -> anyhow::Result<()> { 40 | let source = TestSource::new(vec![TestSegment { 41 | stream_id: 1, 42 | sequence: 1, 43 | file_name: "test.ts".to_string(), 44 | fail_count: Arc::new(AtomicU8::new(2)), 45 | }]); 46 | 47 | let cache = Arc::new(MemoryCacheSource::new()); 48 | 49 | ParallelDownloader::builder(Default::default()) 50 | .app(TracingApp::default()) 51 | .merger(SkipMerger) 52 | .cache(cache.clone()) 53 | .retries(3) 54 | .ctrlc_handler() 55 | .download(source) 56 | .await?; 57 | 58 | let result = cache.into_inner(); 59 | let result = result.lock().unwrap(); 60 | assert_eq!(result.len(), 1); 61 | 62 | Ok(()) 63 | } 64 | -------------------------------------------------------------------------------- /bin/shiori/src/inspect/inspectors/redirect.rs: -------------------------------------------------------------------------------- 1 | use crate::inspect::{Inspect, InspectResult}; 2 | use anyhow::Context; 3 | use clap_handler::async_trait; 4 | use reqwest::redirect::Policy; 5 | use shiori_plugin::*; 6 | 7 | pub struct ShortLinkPlugin; 8 | 9 | impl ShioriPlugin for ShortLinkPlugin { 10 | fn name(&self) -> Cow<'static, str> { 11 | "redirect".into() 12 | } 13 | 14 | fn version(&self) -> Cow<'static, str> { 15 | env!("CARGO_PKG_VERSION").into() 16 | } 17 | 18 | fn description(&self) -> Option> { 19 | Some("Redirects shortlinks to the original URL.".into()) 20 | } 21 | 22 | fn register(&self, registry: &mut dyn InspectorRegistry) -> anyhow::Result<()> { 23 | registry.register_inspector( 24 | Regex::new(r#"^https://t.co/(?.+)$"#).with_context(|| "Invalid t.co regex")?, 25 | Box::new(ShortLinkPlugin), 26 | PriorityHint::Normal, 27 | ); 28 | 29 | Ok(()) 30 | } 31 | } 32 | 33 | #[async_trait] 34 | impl Inspect for ShortLinkPlugin { 35 | fn name(&self) -> Cow<'static, str> { 36 | "redirect".into() 37 | } 38 | 39 | async fn inspect( 40 | &self, 41 | url: &str, 42 | _captures: ®ex::Captures, 43 | _args: &dyn InspectorArguments, 44 | ) -> anyhow::Result { 45 | let client = reqwest::Client::builder() 46 | .danger_accept_invalid_certs(true) 47 | .redirect(Policy::none()) 48 | .build()?; 49 | let response = client.head(url).send().await?; 50 | let location = response 51 | .headers() 52 | .get("location") 53 | .and_then(|l| l.to_str().ok()); 54 | 55 | if let Some(location) = location { 56 | Ok(InspectResult::Redirect(location.to_string())) 57 | } else { 58 | Ok(InspectResult::None) 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /crates/iori/examples/dash_live.rs: -------------------------------------------------------------------------------- 1 | use iori::{ 2 | cache::file::FileCacheSource, dash::live::CommonDashLiveSource, download::ParallelDownloader, 3 | merge::SkipMerger, 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 source = CommonDashLiveSource::new(mpd_url.parse()?, key_str)?; 24 | 25 | let cache_dir = std::env::temp_dir().join("iori_live_dash_example"); 26 | tracing::info!("Using cache directory: {}", cache_dir.display()); 27 | 28 | let cache = FileCacheSource::new(cache_dir)?; 29 | let merger = SkipMerger; 30 | 31 | let downloader = ParallelDownloader::builder(Default::default()) 32 | .app(()) 33 | .cache(cache) 34 | .merger(merger) 35 | .ctrlc_handler(); 36 | 37 | tracing::info!("Starting download for live stream: {}", mpd_url); 38 | match downloader.download(source).await { 39 | Ok(_) => { 40 | tracing::info!( 41 | "Live stream download finished or stopped (e.g., MPD became static or updater task ended)." 42 | ); 43 | } 44 | Err(e) => { 45 | tracing::error!("Download error: {:?}", e); 46 | anyhow::bail!("Download failed: {}", e); 47 | } 48 | } 49 | 50 | Ok(()) 51 | } 52 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /bin/shiori/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "shiori" 3 | description = "A brand new video stream downloader" 4 | version = "0.3.0" 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-ffmpeg = { workspace = true, optional = true } 13 | iori-nicolive.workspace = true 14 | iori-showroom.workspace = true 15 | iori-gigafile.workspace = true 16 | iori-radiko.workspace = true 17 | shiori-plugin-showroom.workspace = true 18 | shiori-plugin-sheeta.workspace = true 19 | shiori-plugin-niconico.workspace = true 20 | shiori-plugin-gigafile.workspace = true 21 | shiori-plugin-radiko.workspace = true 22 | uri-match.workspace = true 23 | 24 | tokio = { workspace = true, features = ["full"] } 25 | reqwest.workspace = true 26 | fake_user_agent.workspace = true 27 | anyhow.workspace = true 28 | log.workspace = true 29 | serde.workspace = true 30 | 31 | clap.workspace = true 32 | clap-handler = { version = "0.1.2", features = ["async"] } 33 | rand.workspace = true 34 | regex.workspace = true 35 | async-recursion.workspace = true 36 | shlex = "1.3.0" 37 | rmp-serde.workspace = true 38 | base64.workspace = true 39 | chrono.workspace = true 40 | ratatui = "0.29.0" 41 | crossterm = "0.29.0" 42 | 43 | shiori-plugin.workspace = true 44 | tracing.workspace = true 45 | tracing-subscriber.workspace = true 46 | self_update = { version = "0.42.0", default-features = false, features = [ 47 | "rustls", 48 | "compression-zip-deflate", 49 | "compression-flate2", 50 | ] } 51 | 52 | i18n-embed = { version = "0.15.4", features = [ 53 | "fluent-system", 54 | "desktop-requester", 55 | "filesystem-assets", 56 | ] } 57 | i18n-embed-fl = "0.9.4" 58 | rust-embed = "8.7.0" 59 | cfg-if = "1.0.4" 60 | 61 | [build-dependencies] 62 | winresource = "0.1.22" 63 | 64 | [features] 65 | default = ["proxy"] 66 | ffmpeg = ["iori-ffmpeg"] 67 | proxy = ["iori/proxy"] 68 | 69 | [[bin]] 70 | name = "shiori" 71 | path = "src/main.rs" 72 | -------------------------------------------------------------------------------- /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 | iori-hls.workspace = true 18 | reqwest.workspace = true 19 | tokio.workspace = true 20 | 21 | aes.workspace = true 22 | cbc.workspace = true 23 | hex = "0.4.3" 24 | mp4decrypt = "0.5.1" 25 | tempfile = "3" 26 | rand.workspace = true 27 | thiserror.workspace = true 28 | url = { version = "2.5.0", features = ["serde"] } 29 | dash-mpd.workspace = true 30 | regex.workspace = true 31 | bytes = "1.6.0" 32 | serde = { workspace = true, features = ["derive"] } 33 | chrono.workspace = true 34 | shlex = "1.3.0" 35 | which = "7.0.2" 36 | reqwest_cookie_store = "0.9.0" 37 | serde_json.workspace = true 38 | tokio-util = { version = "0.7.15", features = ["io"] } 39 | futures = "0.3.31" 40 | sanitize-filename-reader-friendly = "2.3.0" 41 | opendal = { version = "0.54.1", optional = true } 42 | axum = { version = "0.8", optional = true } 43 | tower = { version = "0.5", optional = true } 44 | tower-http = { version = "0.6", features = ["fs", "cors"], optional = true } 45 | 46 | [target.'cfg(not(target_os = "windows"))'.dependencies] 47 | command-fds = { version = "0.3.0", features = ["tokio"] } 48 | 49 | [features] 50 | default = [] 51 | opendal = ["dep:opendal", "tokio-util/compat"] 52 | opendal-fs = ["opendal/services-fs"] 53 | opendal-s3 = ["opendal/services-s3"] 54 | proxy = ["dep:axum", "dep:tower", "dep:tower-http"] 55 | 56 | [dev-dependencies] 57 | anyhow.workspace = true 58 | pretty_env_logger = "0.5.0" 59 | tokio = { workspace = true, features = ["full"] } 60 | tracing-subscriber.workspace = true 61 | wiremock = "0.6.3" 62 | 63 | [[example]] 64 | name = "pipe" 65 | required-features = ["tokio/full"] 66 | 67 | [[example]] 68 | name = "dash_live" 69 | required-features = ["tokio/full"] 70 | -------------------------------------------------------------------------------- /bin/shiori/src/inspect/inspectors/hls.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Context; 2 | use shiori_plugin::*; 3 | 4 | /// A plugin that provides a built-in inspector for HLS playlists. 5 | pub struct HlsPlugin; 6 | 7 | impl ShioriPlugin for HlsPlugin { 8 | fn name(&self) -> Cow<'static, str> { 9 | "hls".into() 10 | } 11 | 12 | fn version(&self) -> Cow<'static, str> { 13 | env!("CARGO_PKG_VERSION").into() 14 | } 15 | 16 | fn description(&self) -> Option> { 17 | Some("A built-in inspector for HLS playlists (.m3u8)".into()) 18 | } 19 | 20 | fn description_long(&self) -> Option> { 21 | Some("Inspects any URL ending in .m3u8 as an HLS playlist.".into()) 22 | } 23 | 24 | fn register(&self, registry: &mut dyn InspectorRegistry) -> anyhow::Result<()> { 25 | registry.register_inspector( 26 | // This regex matches any URL that ends with .m3u8, ignoring query parameters or fragments. 27 | Regex::new(r"\.m3u8($|\?|#)").with_context(|| "Invalid m3u8 regex")?, 28 | Box::new(HlsInspector), 29 | // Set low priority to allow other more specific inspectors to take precedence. 30 | PriorityHint::Low, 31 | ); 32 | Ok(()) 33 | } 34 | } 35 | 36 | /// The inspector implementation for HLS. 37 | struct HlsInspector; 38 | 39 | #[async_trait] 40 | impl Inspect for HlsInspector { 41 | fn name(&self) -> Cow<'static, str> { 42 | "hls".into() 43 | } 44 | 45 | /// The core inspection logic for HLS playlists. 46 | /// 47 | /// This inspector is very simple: it assumes any URL ending in `.m3u8` is a valid 48 | /// HLS playlist and immediately returns it. 49 | async fn inspect( 50 | &self, 51 | url: &str, 52 | _captures: ®ex::Captures, 53 | _args: &dyn InspectorArguments, 54 | ) -> anyhow::Result { 55 | Ok(InspectResult::Playlist(InspectPlaylist { 56 | playlist_url: url.to_string(), 57 | playlist_type: PlaylistType::HLS, 58 | ..Default::default() 59 | })) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /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-experimental-ui = {"["}Experimental] Enable TUI 5 | download-url = URL to download 6 | 7 | download-http-headers = Additional HTTP headers for all HTTP requests, format is key: value 8 | download-http-cookies = 9 | {"["}Advanced] Additional HTTP cookies 10 | 11 | Will not take effect if `Cookies` is set in `headers`. 12 | Do not use this option unless you know what you are doing. 13 | download-http-timeout = HTTP timeout, in seconds 14 | download-http-http1-only = Force to use HTTP/1.1 for requests 15 | 16 | download-concurrency = Threads limit 17 | download-segment-retries = Segment retry limit 18 | # download-segment-retry-delay = Set retry delay after download fails in seconds 19 | download-manifest-retries = Manifest retry limit 20 | 21 | download-cache-in-menory-cache = Use in-memory cache and do not write cache to disk while downloading 22 | download-cache-temp-dir = 23 | Temporary directory 24 | 25 | The default temp dir is the current directory or the system temp dir. 26 | Will not take effect if `cache_dir` is set. 27 | download-cache-cache-dir = 28 | {"["}Advanced] Cache directory 29 | 30 | Speficy a directory to store cache files. 31 | 32 | If specified, the cache will be stored in this directory directly without creating a subdirectory. 33 | download-cache-experimental-stream-dir-cache = 34 | {"["}Experimental] Use new cache directory structure 35 | 36 | Resume download is supported in this cache source. Make sure to use along with `cache-dir`. 37 | 38 | download-output-no-merge = Do not merge stream 39 | download-output-concat = Merge files using concat 40 | download-output-output = Output filename 41 | download-output-pipe = Pipe to stdout 42 | download-output-pipe-mux = Mux with ffmpeg. Only works when `--pipe` is set. 43 | download-output-pipe-to = Pipe to a file 44 | download-output-experimental-proxy = {"["}Experimental] Provide a M3U8 manifest for other clients by starting a local HTTP Server 45 | download-output-no-recycle = Keep the downloaded segments 46 | -------------------------------------------------------------------------------- /bin/shiori/src/inspect/inspectors/dash.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Context; 2 | 3 | use shiori_plugin::*; 4 | 5 | /// A plugin that provides a built-in inspector for MPEG-DASH manifests. 6 | pub struct DashPlugin; 7 | 8 | impl ShioriPlugin for DashPlugin { 9 | fn name(&self) -> Cow<'static, str> { 10 | "dash".into() 11 | } 12 | 13 | fn version(&self) -> Cow<'static, str> { 14 | env!("CARGO_PKG_VERSION").into() 15 | } 16 | 17 | fn description(&self) -> Option> { 18 | Some("A built-in inspector for MPEG-DASH manifests (.mpd)".into()) 19 | } 20 | 21 | fn description_long(&self) -> Option> { 22 | Some("Inspects any URL ending in .mpd as a MPEG-DASH manifest.".into()) 23 | } 24 | 25 | fn register(&self, registry: &mut dyn InspectorRegistry) -> anyhow::Result<()> { 26 | registry.register_inspector( 27 | // This regex matches any URL that ends with .mpd, ignoring query parameters or fragments. 28 | Regex::new(r"\.mpd($|\?|#)").with_context(|| "Invalid mpd regex")?, 29 | Box::new(DashInspector), 30 | // Set low priority to allow other more specific inspectors to take precedence. 31 | PriorityHint::Low, 32 | ); 33 | Ok(()) 34 | } 35 | } 36 | 37 | /// The inspector implementation for MPEG-DASH. 38 | struct DashInspector; 39 | 40 | #[async_trait] 41 | impl Inspect for DashInspector { 42 | fn name(&self) -> Cow<'static, str> { 43 | "dash".into() 44 | } 45 | 46 | /// The core inspection logic for DASH manifests. 47 | /// 48 | /// This inspector is very simple: it assumes any URL ending in `.mpd` is a valid 49 | /// DASH playlist and immediately returns it. 50 | async fn inspect( 51 | &self, 52 | url: &str, 53 | _captures: ®ex::Captures, 54 | _args: &dyn InspectorArguments, 55 | ) -> anyhow::Result { 56 | Ok(InspectResult::Playlist(InspectPlaylist { 57 | playlist_url: url.to_string(), 58 | playlist_type: PlaylistType::DASH, 59 | ..Default::default() 60 | })) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /crates/iori/src/raw/segments.rs: -------------------------------------------------------------------------------- 1 | use futures::{Stream, stream}; 2 | use std::sync::Mutex; 3 | 4 | use crate::{ 5 | ByteRange, IoriResult, RemoteStreamingSegment, StreamType, StreamingSegment, StreamingSource, 6 | context::IoriContext, 7 | }; 8 | 9 | #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] 10 | pub struct RawRemoteSegment { 11 | pub url: reqwest::Url, 12 | pub filename: String, 13 | pub range: Option, 14 | 15 | pub stream_id: u64, 16 | pub sequence: u64, 17 | pub stream_type: StreamType, 18 | } 19 | 20 | impl StreamingSegment for RawRemoteSegment { 21 | fn stream_id(&self) -> u64 { 22 | self.stream_id 23 | } 24 | 25 | fn sequence(&self) -> u64 { 26 | self.sequence 27 | } 28 | 29 | fn file_name(&self) -> &str { 30 | &self.filename 31 | } 32 | 33 | fn key(&self) -> Option> { 34 | // TODO: Support key 35 | None 36 | } 37 | 38 | fn stream_type(&self) -> crate::StreamType { 39 | self.stream_type 40 | } 41 | 42 | fn format(&self) -> crate::SegmentFormat { 43 | crate::SegmentFormat::from_filename(&self.filename) 44 | } 45 | } 46 | 47 | impl RemoteStreamingSegment for RawRemoteSegment { 48 | fn url(&self) -> reqwest::Url { 49 | self.url.clone() 50 | } 51 | 52 | fn byte_range(&self) -> Option { 53 | self.range.clone() 54 | } 55 | } 56 | 57 | pub struct RawRemoteSegmentsSource { 58 | segments: Mutex>, 59 | } 60 | 61 | impl RawRemoteSegmentsSource { 62 | pub fn new(segments: Vec) -> Self { 63 | Self { 64 | segments: Mutex::new(segments), 65 | } 66 | } 67 | } 68 | 69 | impl StreamingSource for RawRemoteSegmentsSource { 70 | type Segment = RawRemoteSegment; 71 | 72 | async fn segments_stream( 73 | &self, 74 | _: &IoriContext, 75 | ) -> IoriResult>>> { 76 | let segments = self.segments.lock().unwrap().drain(..).collect(); 77 | 78 | Ok(stream::once(async move { Ok(segments) })) 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /platforms/nicolive/src/source.rs: -------------------------------------------------------------------------------- 1 | use iori::{ 2 | HttpClient, IoriResult, Stream, StreamingSource, 3 | context::IoriContext, 4 | hls::{HlsLiveSource, segment::M3u8Segment}, 5 | }; 6 | use serde::{Deserialize, Serialize}; 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(HlsLiveSource); 18 | 19 | impl NicoTimeshiftSource { 20 | pub async fn new( 21 | client: HttpClient, 22 | wss_url: String, 23 | quality: &str, 24 | chase_play: bool, 25 | ) -> anyhow::Result { 26 | let watcher = crate::watch::WatchClient::new(&wss_url).await?; 27 | watcher.start_watching(quality, chase_play).await?; 28 | 29 | let stream = loop { 30 | let msg = watcher.recv().await?; 31 | if let Some(WatchResponse::Stream(stream)) = msg { 32 | break stream; 33 | } 34 | }; 35 | 36 | log::info!("Playlist: {}", stream.uri); 37 | let url = Url::parse(&stream.uri)?; 38 | client.add_cookies(stream.cookies.into_cookies(), url); 39 | 40 | // keep seats 41 | tokio::spawn(async move { 42 | loop { 43 | tokio::select! { 44 | msg = watcher.recv() => { 45 | let Ok(msg) = msg else { 46 | break; 47 | }; 48 | log::debug!("message: {:?}", msg); 49 | } 50 | _ = watcher.keep_seat() => (), 51 | } 52 | } 53 | log::info!("watcher disconnected"); 54 | }); 55 | 56 | Ok(Self(HlsLiveSource::new(stream.uri, None)?)) 57 | } 58 | } 59 | 60 | impl StreamingSource for NicoTimeshiftSource { 61 | type Segment = M3u8Segment; 62 | 63 | async fn segments_stream( 64 | &self, 65 | context: &IoriContext, 66 | ) -> IoriResult>>> { 67 | self.0.segments_stream(context).await 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /bin/shiori/src/i18n.rs: -------------------------------------------------------------------------------- 1 | use std::sync::LazyLock; 2 | 3 | use i18n_embed::{ 4 | DesktopLanguageRequester, LanguageLoader, 5 | fluent::{FluentLanguageLoader, fluent_language_loader}, 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 | -------------------------------------------------------------------------------- /crates/iori-ffmpeg/build/linux_ffmpeg.rs: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | #![allow(unused_attributes)] /* 3 | OUT=/tmp/tmp && rustc "$0" -o ${OUT} && exec ${OUT} $@ || exit $? #*/ 4 | 5 | use std::fs; 6 | use std::io::Result; 7 | use std::path::PathBuf; 8 | use std::process::Command; 9 | 10 | fn mkdir(dir_name: &str) -> Result<()> { 11 | fs::create_dir(dir_name) 12 | } 13 | 14 | fn pwd() -> Result { 15 | std::env::current_dir() 16 | } 17 | 18 | fn cd(dir_name: &str) -> Result<()> { 19 | std::env::set_current_dir(dir_name) 20 | } 21 | 22 | fn main() -> Result<()> { 23 | let _ = mkdir("tmp"); 24 | 25 | cd("tmp")?; 26 | 27 | let tmp_path = pwd()?.to_string_lossy().to_string(); 28 | let build_path = format!("{}/ffmpeg_build", tmp_path); 29 | let branch = std::env::args() 30 | .nth(1) 31 | .unwrap_or_else(|| "release/7.1".to_string()); 32 | let num_job = std::thread::available_parallelism().unwrap().get(); 33 | 34 | if fs::metadata("ffmpeg").is_err() { 35 | Command::new("git") 36 | .arg("clone") 37 | .arg("--single-branch") 38 | .arg("--branch") 39 | .arg(&branch) 40 | .arg("--depth") 41 | .arg("1") 42 | .arg("https://github.com/ffmpeg/ffmpeg") 43 | .status()?; 44 | } 45 | 46 | cd("ffmpeg")?; 47 | 48 | Command::new("git") 49 | .arg("fetch") 50 | .arg("origin") 51 | .arg(&branch) 52 | .arg("--depth") 53 | .arg("1") 54 | .status()?; 55 | 56 | Command::new("git") 57 | .arg("checkout") 58 | .arg("FETCH_HEAD") 59 | .status()?; 60 | 61 | Command::new("./configure") 62 | .arg(format!("--prefix={}", build_path)) 63 | // To workaround `https://github.com/larksuite/rsmpeg/pull/98#issuecomment-1467511193` 64 | .arg("--disable-decoder=exr,phm") 65 | .arg("--disable-programs") 66 | .arg("--disable-autodetect") 67 | .status()?; 68 | 69 | Command::new("make") 70 | .arg("-j") 71 | .arg(num_job.to_string()) 72 | .status()?; 73 | 74 | Command::new("make").arg("install").status()?; 75 | 76 | cd("..")?; 77 | 78 | Ok(()) 79 | } 80 | -------------------------------------------------------------------------------- /crates/iori/src/download/sequencial.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use futures::StreamExt; 4 | use tokio::io::AsyncWriteExt; 5 | 6 | use crate::{ 7 | IoriError, SegmentInfo, StreamingSource, WriteSegment, cache::CacheSource, 8 | context::IoriContext, error::IoriResult, merge::Merger, 9 | }; 10 | 11 | pub struct SequencialDownloader 12 | where 13 | S: StreamingSource, 14 | M: Merger, 15 | C: CacheSource, 16 | { 17 | context: IoriContext, 18 | 19 | source: S, 20 | merger: M, 21 | cache: Arc, 22 | } 23 | 24 | impl SequencialDownloader 25 | where 26 | S: StreamingSource, 27 | M: Merger, 28 | C: CacheSource, 29 | { 30 | pub fn new(context: IoriContext, source: S, merger: M, cache: C) -> Self { 31 | Self { 32 | context, 33 | source, 34 | merger, 35 | cache: Arc::new(cache), 36 | } 37 | } 38 | 39 | pub async fn download(&mut self) -> IoriResult<()> { 40 | let stream = self.source.segments_stream(&self.context).await?; 41 | tokio::pin!(stream); 42 | 43 | while let Some(segment) = stream.next().await { 44 | for segment in segment? { 45 | let segment_info = SegmentInfo::from(&segment); 46 | let writer = self.cache.open_writer(&segment_info).await?; 47 | let Some(mut writer) = writer else { 48 | continue; 49 | }; 50 | 51 | let fetch_result = segment.write_segment(&self.context, &mut writer).await; 52 | let fetch_result = match fetch_result { 53 | // graceful shutdown 54 | Ok(_) => writer.shutdown().await.map_err(IoriError::IOError), 55 | Err(e) => Err(e), 56 | }; 57 | drop(writer); 58 | 59 | match fetch_result { 60 | Ok(_) => self.merger.update(segment_info, self.cache.clone()).await?, 61 | Err(_) => self.merger.fail(segment_info, self.cache.clone()).await?, 62 | } 63 | } 64 | } 65 | 66 | self.merger.finish(self.cache.clone()).await?; 67 | Ok(()) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /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 | rustPlatform.bindgenHook 41 | ]; 42 | buildInputs = with pkgs; [ 43 | protobuf 44 | ]; 45 | cargoBuildOptions = opts: opts ++ [ "--workspace" ]; 46 | }; 47 | 48 | devShells.default = pkgs.mkShell { 49 | buildInputs = with pkgs; [ 50 | rustToolchain 51 | rust-analyzer 52 | pkg-config 53 | rustPlatform.bindgenHook # for clang 54 | nasm 55 | protobuf 56 | gcc # ffmpeg uses gcc to check host C11 support 57 | 58 | mkvtoolnix-cli 59 | ]; 60 | env = { 61 | LC_ALL = "C"; 62 | }; 63 | shellHook = '' 64 | pushd crates/iori-ffmpeg 65 | ./build/build.rs 66 | 67 | export FFMPEG_INCLUDE_DIR="$PWD/tmp/ffmpeg_build/include" 68 | export FFMPEG_PKG_CONFIG_PATH="$PWD/tmp/ffmpeg_build/lib/pkgconfig" 69 | export PKG_CONFIG_PATH_FOR_TARGET="$PKG_CONFIG_PATH_FOR_TARGET:$FFMPEG_PKG_CONFIG_PATH" 70 | popd 71 | ''; 72 | }; 73 | } 74 | ); 75 | } 76 | -------------------------------------------------------------------------------- /platforms/sheeta/src/model.rs: -------------------------------------------------------------------------------- 1 | #![allow(dead_code)] 2 | 3 | use serde::Deserialize; 4 | 5 | #[derive(Debug, Deserialize)] 6 | pub struct SiteSettings { 7 | platform_id: String, 8 | fanclub_site_id: String, 9 | fanclub_group_id: String, 10 | pub(crate) api_base_url: String, 11 | } 12 | 13 | #[derive(Debug, Deserialize)] 14 | pub struct EqPortalResponse { 15 | data: T, 16 | } 17 | 18 | pub type FcVideoPageResponse = EqPortalResponse; 19 | 20 | impl FcVideoPageResponse { 21 | pub fn fc_site_id(self) -> i32 { 22 | self.data.video_page.fanclub_site.id 23 | } 24 | 25 | pub fn title(self) -> String { 26 | self.data.video_page.title 27 | } 28 | } 29 | 30 | #[derive(Debug, Deserialize)] 31 | pub struct FcVideoPageData { 32 | video_page: VideoPage, 33 | } 34 | 35 | #[derive(Debug, Deserialize)] 36 | pub struct VideoPage { 37 | title: String, 38 | description: String, 39 | fanclub_site: FanclubSite, 40 | video_tags: Vec, 41 | } 42 | 43 | #[derive(Debug, Deserialize)] 44 | pub struct VideoTag { 45 | id: i32, 46 | tag: String, 47 | } 48 | 49 | #[derive(Debug, Deserialize)] 50 | pub struct FanclubSite { 51 | id: i32, 52 | } 53 | 54 | pub type SessionIdResponse = EqPortalResponse; 55 | 56 | impl SessionIdResponse { 57 | pub fn session_id(self) -> String { 58 | self.data.session_id 59 | } 60 | } 61 | 62 | // {"data":{"session_id":"eeff71a4-5fa3-4f1f-9ced-c2c7894c79b8"}} 63 | #[derive(Debug, Deserialize)] 64 | pub struct SessionIdData { 65 | session_id: String, 66 | } 67 | 68 | pub type FcContentProviderResponse = EqPortalResponse; 69 | 70 | impl FcContentProviderResponse { 71 | pub fn fc_site_id(self) -> i32 { 72 | self.data.content_providers.id 73 | } 74 | } 75 | 76 | // { 77 | // "data": { 78 | // "content_providers": { 79 | // "domain": "https://qlover.jp/non", 80 | // "fanclub_site": { 81 | // "id": 744 82 | // }, 83 | // "id": 744 84 | // } 85 | // } 86 | // } 87 | #[derive(Debug, Deserialize)] 88 | pub struct FcContentProviderData { 89 | content_providers: ContentProvider, 90 | } 91 | 92 | #[derive(Debug, Deserialize)] 93 | pub struct ContentProvider { 94 | domain: String, 95 | id: i32, 96 | } 97 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = ["bin/*", "crates/*", "plugins/*", "platforms/*"] 3 | resolver = "2" 4 | 5 | [workspace.package] 6 | edition = "2024" 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-ffmpeg = { path = "crates/iori-ffmpeg" } 14 | iori-ssa = { path = "crates/ssa" } 15 | iori-hls = { path = "crates/iori-hls" } 16 | 17 | iori-nicolive = { path = "platforms/nicolive" } 18 | iori-showroom = { path = "platforms/showroom" } 19 | iori-gigafile = { path = "platforms/gigafile" } 20 | iori-sheeta = { path = "platforms/sheeta" } 21 | iori-radiko = { path = "platforms/radiko" } 22 | 23 | shiori-plugin = { path = "plugins/plugin" } 24 | shiori-plugin-showroom = { path = "plugins/plugin-showroom" } 25 | shiori-plugin-sheeta = { path = "plugins/plugin-sheeta" } 26 | shiori-plugin-niconico = { path = "plugins/plugin-niconico" } 27 | shiori-plugin-gigafile = { path = "plugins/plugin-gigafile" } 28 | shiori-plugin-radiko = { path = "plugins/plugin-radiko" } 29 | 30 | uri-match = { path = "crates/uri-match" } 31 | 32 | regex = "1.9.3" 33 | base64 = "0.22.1" 34 | tokio = { version = "1", features = [ 35 | "signal", 36 | "process", 37 | "net", 38 | "io-std", 39 | "rt-multi-thread", 40 | ] } 41 | 42 | fake_user_agent = "0.2.1" 43 | anyhow = "1.0" 44 | thiserror = "2" 45 | log = "0.4" 46 | tracing = "0.1" 47 | tracing-subscriber = { version = "0.3.20", features = ["env-filter", "time"] } 48 | 49 | serde = { version = "1.0", features = ["derive"] } 50 | serde_json = "1.0" 51 | rmp-serde = "1.3.0" 52 | prost = "0.14" 53 | prost-types = "0.14" 54 | prost-build = "0.14" 55 | 56 | aes = "0.8.4" 57 | cbc = { version = "0.1.2", features = ["std"] } 58 | 59 | reqwest = { version = "^0.12.24", default-features = false, features = [ 60 | "rustls-tls", 61 | "stream", 62 | "json", 63 | "socks", 64 | "cookies", 65 | ] } 66 | chrono = "0.4" 67 | 68 | clap = { version = "4.5.34", features = ["derive", "env"] } 69 | 70 | async-recursion = "1.1.1" 71 | 72 | rand = "0.9.2" 73 | getrandom = { version = "0.2", features = ["js"] } 74 | 75 | dash-mpd = { version = "0.18", default-features = false, features = ["scte35"] } 76 | 77 | [patch.crates-io] 78 | bento4-src = { git = "https://github.com/iori-rs/vsd" } 79 | -------------------------------------------------------------------------------- /crates/iori/src/raw/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::{IoriResult, StreamingSegment, StreamingSource, WriteSegment, context::IoriContext}; 2 | use futures::{Stream, stream}; 3 | use std::{borrow::Cow, path::PathBuf}; 4 | use tokio::io::{AsyncWrite, AsyncWriteExt}; 5 | 6 | mod http; 7 | pub use http::*; 8 | 9 | mod segments; 10 | pub use segments::*; 11 | 12 | pub struct RawDataSource { 13 | data: String, 14 | ext: String, 15 | } 16 | 17 | impl RawDataSource { 18 | pub fn new(data: String, url: String) -> Self { 19 | let ext = PathBuf::from(url) 20 | .extension() 21 | .map(|e| e.to_string_lossy()) 22 | .unwrap_or(Cow::Borrowed("raw")) 23 | .to_string(); 24 | 25 | Self { data, ext } 26 | } 27 | } 28 | 29 | pub struct RawSegment { 30 | data: String, 31 | filename: String, 32 | ext: String, 33 | } 34 | 35 | impl RawSegment { 36 | pub fn new(data: String, ext: String) -> Self { 37 | Self { 38 | data, 39 | filename: format!("data.{ext}"), 40 | ext, 41 | } 42 | } 43 | } 44 | 45 | impl StreamingSegment for RawSegment { 46 | fn stream_id(&self) -> u64 { 47 | 0 48 | } 49 | 50 | fn sequence(&self) -> u64 { 51 | 0 52 | } 53 | 54 | fn file_name(&self) -> &str { 55 | &self.filename 56 | } 57 | 58 | fn key(&self) -> Option> { 59 | None 60 | } 61 | 62 | fn stream_type(&self) -> crate::StreamType { 63 | crate::StreamType::Unknown 64 | } 65 | 66 | fn format(&self) -> crate::SegmentFormat { 67 | crate::SegmentFormat::Raw(Some(self.ext.clone())) 68 | } 69 | } 70 | 71 | impl StreamingSource for RawDataSource { 72 | type Segment = RawSegment; 73 | 74 | async fn segments_stream( 75 | &self, 76 | _: &IoriContext, 77 | ) -> IoriResult>>> { 78 | Ok(Box::pin(stream::once(async move { 79 | Ok(vec![RawSegment::new(self.data.clone(), self.ext.clone())]) 80 | }))) 81 | } 82 | } 83 | 84 | impl WriteSegment for RawSegment { 85 | async fn write_segment(&self, _: &IoriContext, writer: &mut W) -> IoriResult<()> 86 | where 87 | W: AsyncWrite + Unpin + Send, 88 | { 89 | writer.write_all(self.data.as_bytes()).await?; 90 | Ok(()) 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /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 | .identifier(if cfg!(feature = "ffmpeg") { 42 | "shiori-ffmpeg" 43 | } else { 44 | "shiori-core" 45 | }) 46 | .target(&target) 47 | .target_version_tag(&target_version_tag) 48 | .show_download_progress(true) 49 | .current_version(cargo_crate_version!()) 50 | .no_confirm(me.skip_confirm) 51 | .build()? 52 | .update()?; 53 | 54 | println!("Update status: `{}`!", status.updated()); 55 | 56 | Ok(()) 57 | } 58 | 59 | pub(crate) async fn check_update() -> anyhow::Result<()> { 60 | let current_version = format!("shiori-v{}", cargo_crate_version!()); 61 | 62 | let latest = reqwest::Client::new() 63 | .get( 64 | "https://raw.githubusercontent.com/Yesterday17/iori/refs/heads/master/.versions/shiori", 65 | ) 66 | .timeout(std::time::Duration::from_secs(5)) 67 | .send() 68 | .await? 69 | .text() 70 | .await?; 71 | 72 | if current_version == latest { 73 | return Ok(()); 74 | } 75 | log::info!( 76 | "Update available: {}. Please run `shiori update` to update.", 77 | latest 78 | ); 79 | 80 | Ok(()) 81 | } 82 | -------------------------------------------------------------------------------- /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 | 61 | pub trait UriExt { 62 | fn filename(&self) -> Option; 63 | } 64 | 65 | impl UriExt for Url { 66 | fn filename(&self) -> Option { 67 | self.path().rsplit_once('/').map(|o| o.1.to_string()) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /crates/iori-ffmpeg/build/macos_ffmpeg_cross.rs: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | #![allow(unused_attributes)] /* 3 | OUT=/tmp/tmp && rustc "$0" -o ${OUT} && exec ${OUT} $@ || exit $? #*/ 4 | 5 | use std::fs; 6 | use std::io::Result; 7 | use std::path::PathBuf; 8 | use std::process::Command; 9 | 10 | fn mkdir(dir_name: &str) -> Result<()> { 11 | fs::create_dir(dir_name) 12 | } 13 | 14 | fn pwd() -> Result { 15 | std::env::current_dir() 16 | } 17 | 18 | fn cd(dir_name: &str) -> Result<()> { 19 | std::env::set_current_dir(dir_name) 20 | } 21 | 22 | fn main() -> Result<()> { 23 | let arch = "x86_64"; 24 | 25 | let _ = mkdir("tmp"); 26 | 27 | cd("tmp")?; 28 | 29 | let tmp_path = pwd()?.to_string_lossy().to_string(); 30 | let build_path = format!("{}/ffmpeg_build", tmp_path); 31 | let branch = std::env::args() 32 | .nth(1) 33 | .unwrap_or_else(|| "release/7.1".to_string()); 34 | let num_job = std::thread::available_parallelism().unwrap().get(); 35 | 36 | if fs::metadata("ffmpeg").is_err() { 37 | Command::new("git") 38 | .arg("clone") 39 | .arg("--single-branch") 40 | .arg("--branch") 41 | .arg(&branch) 42 | .arg("--depth") 43 | .arg("1") 44 | .arg("https://github.com/ffmpeg/ffmpeg") 45 | .status()?; 46 | } 47 | 48 | cd("ffmpeg")?; 49 | 50 | Command::new("git") 51 | .arg("fetch") 52 | .arg("origin") 53 | .arg(&branch) 54 | .arg("--depth") 55 | .arg("1") 56 | .status()?; 57 | 58 | Command::new("git") 59 | .arg("checkout") 60 | .arg("FETCH_HEAD") 61 | .status()?; 62 | 63 | // Force x86_64 target flags so configure tests use the intended arch. 64 | Command::new("./configure") 65 | .arg(format!("--prefix={}", build_path)) 66 | .arg("--enable-cross-compile") 67 | .arg("--target-os=darwin") 68 | .arg(format!("--arch={arch}")) 69 | .arg("--cc=clang") 70 | .arg("--cxx=clang++") 71 | .arg(format!("--extra-cflags=-arch {arch}")) 72 | .arg(format!("--extra-cxxflags=-arch {arch}")) 73 | .arg(format!("--extra-ldflags=-arch {arch}")) 74 | // To workaround `https://github.com/larksuite/rsmpeg/pull/98#issuecomment-1467511193` 75 | .arg("--disable-decoder=exr,phm") 76 | .arg("--disable-programs") 77 | .status()?; 78 | 79 | Command::new("make") 80 | .arg("-j") 81 | .arg(num_job.to_string()) 82 | .status()?; 83 | 84 | Command::new("make").arg("install").status()?; 85 | 86 | cd("..")?; 87 | 88 | Ok(()) 89 | } 90 | -------------------------------------------------------------------------------- /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) -> BestRepresentationSelector { 6 | BestRepresentationSelector { 7 | width: representation.width, 8 | height: representation.height, 9 | bandwidth: representation.bandwidth, 10 | } 11 | } 12 | 13 | #[derive(PartialEq, Eq)] 14 | pub(crate) 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 | -------------------------------------------------------------------------------- /plugins/plugin-gigafile/src/lib.rs: -------------------------------------------------------------------------------- 1 | use fake_user_agent::get_chrome_rua; 2 | use iori_gigafile::GigafileClient; 3 | use shiori_plugin::iori::reqwest::{ 4 | Client, 5 | header::{CONTENT_DISPOSITION, COOKIE, USER_AGENT}, 6 | }; 7 | use shiori_plugin::*; 8 | 9 | pub struct GigafilePlugin; 10 | 11 | impl ShioriPlugin for GigafilePlugin { 12 | fn name(&self) -> Cow<'static, str> { 13 | "gigafile".into() 14 | } 15 | 16 | fn version(&self) -> Cow<'static, str> { 17 | "0.1.0".into() 18 | } 19 | 20 | fn description(&self) -> Option> { 21 | Some("Extracts raw download URL from Gigafile.".into()) 22 | } 23 | 24 | fn arguments(&self, command: &mut dyn InspectorCommand) { 25 | command.add_argument("giga-key", Some("key"), "[Gigafile] Download key"); 26 | } 27 | 28 | fn register(&self, registry: &mut dyn InspectorRegistry) -> anyhow::Result<()> { 29 | let regex = Regex::new(r"https://(\d+)\.gigafile\.nu/.*").unwrap(); 30 | registry.register_inspector(regex, Box::new(GigafileInspector), PriorityHint::Normal); 31 | Ok(()) 32 | } 33 | } 34 | 35 | struct GigafileInspector; 36 | 37 | #[async_trait] 38 | impl Inspect for GigafileInspector { 39 | fn name(&self) -> Cow<'static, str> { 40 | "gigafile".into() 41 | } 42 | 43 | async fn inspect( 44 | &self, 45 | url: &str, 46 | _captures: &Captures, 47 | args: &dyn InspectorArguments, 48 | ) -> anyhow::Result { 49 | let key = args.get_string("giga-key"); 50 | let client = GigafileClient::new(key); 51 | let (url, cookie) = client.get_download_url(url.try_into()?).await?; 52 | 53 | let client = Client::builder() 54 | .danger_accept_invalid_certs(true) 55 | .build() 56 | .unwrap(); 57 | let response = client 58 | .get(&url) 59 | .header(COOKIE, &cookie) 60 | .header(USER_AGENT, get_chrome_rua()) 61 | .send() 62 | .await?; 63 | let filename = response.headers().get(CONTENT_DISPOSITION).and_then(|v| { 64 | // attachment; filename=""; 65 | let re = regex::bytes::Regex::new(r#"filename="([^"]+)""#).unwrap(); 66 | let matched = re 67 | .captures(v.as_bytes()) 68 | .and_then(|c| c.get(1).map(|m| m.as_bytes()))?; 69 | let filename = String::from_utf8(matched.to_vec()).ok()?; 70 | Some(filename) 71 | }); 72 | drop(response); 73 | 74 | Ok(InspectResult::Playlist(InspectPlaylist { 75 | title: filename, 76 | playlist_url: url, 77 | playlist_type: PlaylistType::Http, 78 | headers: vec![format!("Cookie: {cookie}")], 79 | ..Default::default() 80 | })) 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /crates/iori-ffmpeg/build/windows_ffmpeg_cross.rs: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | #![allow(unused_attributes)] /* 3 | OUT=/tmp/tmp && rustc "$0" -o ${OUT} && exec ${OUT} $@ || exit $? #*/ 4 | 5 | use std::fs; 6 | use std::io::Result; 7 | use std::path::PathBuf; 8 | use std::process::Command; 9 | 10 | fn mkdir(dir_name: &str) -> Result<()> { 11 | fs::create_dir(dir_name) 12 | } 13 | 14 | fn pwd() -> Result { 15 | std::env::current_dir() 16 | } 17 | 18 | fn cd(dir_name: &str) -> Result<()> { 19 | std::env::set_current_dir(dir_name) 20 | } 21 | 22 | fn main() -> Result<()> { 23 | let _ = mkdir("tmp"); 24 | 25 | cd("tmp")?; 26 | 27 | let tmp_path = pwd()?.to_string_lossy().to_string(); 28 | let build_path = format!("{}/ffmpeg_build", tmp_path); 29 | let branch = std::env::args() 30 | .nth(1) 31 | .unwrap_or_else(|| "release/7.1".to_string()); 32 | let num_job = std::thread::available_parallelism().unwrap().get(); 33 | 34 | if fs::metadata("ffmpeg").is_err() { 35 | Command::new("git") 36 | .arg("clone") 37 | .arg("--single-branch") 38 | .arg("--branch") 39 | .arg(&branch) 40 | .arg("--depth") 41 | .arg("1") 42 | .arg("https://github.com/ffmpeg/ffmpeg") 43 | .status()?; 44 | } 45 | 46 | cd("ffmpeg")?; 47 | 48 | Command::new("git") 49 | .arg("fetch") 50 | .arg("origin") 51 | .arg(&branch) 52 | .arg("--depth") 53 | .arg("1") 54 | .status()?; 55 | 56 | Command::new("git") 57 | .arg("checkout") 58 | .arg("FETCH_HEAD") 59 | .status()?; 60 | 61 | Command::new("./configure") 62 | .arg(format!("--prefix={}", build_path)) 63 | // To workaround `https://github.com/larksuite/rsmpeg/pull/98#issuecomment-1467511193` 64 | .arg("--disable-decoder=exr,phm") 65 | .arg("--disable-programs") 66 | .arg("--disable-autodetect") 67 | .arg("--arch=x86_64") 68 | .arg("--target-os=mingw32") 69 | .arg("--cross-prefix=x86_64-w64-mingw32-") 70 | .arg("--pkg-config=pkg-config") 71 | .arg("--enable-static") 72 | .arg("--disable-shared") 73 | // https://github.com/elan-ev/static-ffmpeg/blob/ffb12599ea77149bb91d5ecb37304ee96a546c29/build_ffmpeg.sh#L494C24-L497 74 | .arg("--pkg-config-flags=--static") 75 | .arg("--extra-libs=-lstdc++") 76 | .arg("--extra-cflags=-static -static-libgcc") 77 | .arg("--extra-cxxflags=-static -static-libgcc -static-libstdc++") 78 | .arg("--extra-ldflags=-static -static-libgcc -static-libstdc++") 79 | .status()?; 80 | 81 | Command::new("make") 82 | .arg("-j") 83 | .arg(num_job.to_string()) 84 | .status()?; 85 | 86 | Command::new("make").arg("install").status()?; 87 | 88 | cd("..")?; 89 | 90 | Ok(()) 91 | } 92 | -------------------------------------------------------------------------------- /plugins/plugin-sheeta/src/lib.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Context; 2 | use iori_sheeta::client::SheetaClient; 3 | use shiori_plugin::*; 4 | 5 | pub struct SheetaPlugin; 6 | 7 | impl ShioriPlugin for SheetaPlugin { 8 | fn name(&self) -> Cow<'static, str> { 9 | "sheeta".into() 10 | } 11 | 12 | fn version(&self) -> Cow<'static, str> { 13 | "0.1.0".into() 14 | } 15 | 16 | fn description(&self) -> Option> { 17 | Some("Extract videos from nicochannel+ based platforms.".into()) 18 | } 19 | 20 | fn register(&self, registry: &mut dyn InspectorRegistry) -> anyhow::Result<()> { 21 | registry.register_inspector( 22 | SheetaClient::site_regex("nicochannel.jp"), 23 | Box::new(SheetaInspector { 24 | name: "nicochannel+", 25 | host: Some("nicochannel.jp".to_string()), 26 | }), 27 | PriorityHint::Normal, 28 | ); 29 | registry.register_inspector( 30 | SheetaClient::site_regex("qlover.jp"), 31 | Box::new(SheetaInspector { 32 | name: "qlover+", 33 | host: Some("qlover.jp".to_string()), 34 | }), 35 | PriorityHint::Normal, 36 | ); 37 | registry.register_inspector( 38 | SheetaClient::wild_regex(), 39 | Box::new(SheetaInspector { 40 | name: "sheeta", 41 | host: None, 42 | }), 43 | PriorityHint::Low, 44 | ); 45 | 46 | Ok(()) 47 | } 48 | } 49 | 50 | struct SheetaInspector { 51 | name: &'static str, 52 | host: Option, 53 | } 54 | 55 | #[async_trait] 56 | impl Inspect for SheetaInspector { 57 | fn name(&self) -> Cow<'static, str> { 58 | self.name.into() 59 | } 60 | 61 | async fn inspect( 62 | &self, 63 | _url: &str, 64 | captures: &Captures, 65 | _args: &dyn InspectorArguments, 66 | ) -> anyhow::Result { 67 | let host = captures 68 | .name("host") 69 | .map(|s| s.as_str()) 70 | .or(self.host.as_deref()) 71 | .with_context(|| "Missing sheeta host")?; 72 | let client = SheetaClient::common(host).await?; 73 | 74 | let video_id = captures 75 | .name("video_id") 76 | .with_context(|| "Missing sheeta video id")? 77 | .as_str(); 78 | 79 | let session_id = client.get_session_id(0, video_id).await?; 80 | let video_url = client.get_video_url(&session_id).await; 81 | Ok(InspectResult::Playlist(InspectPlaylist { 82 | playlist_url: video_url, 83 | headers: vec![ 84 | format!("Referer: {}", client.origin()), 85 | format!("Origin: {}", client.origin()), 86 | ], 87 | ..Default::default() 88 | })) 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /platforms/radiko/src/time.rs: -------------------------------------------------------------------------------- 1 | use std::ops::{Add, AddAssign}; 2 | use std::time::Duration as StdDuration; 3 | 4 | use chrono::{DateTime, Duration, FixedOffset, NaiveDateTime, TimeZone, Timelike}; 5 | 6 | /// Japanese Standard Time offset 7 | pub const JST: FixedOffset = FixedOffset::east_opt(9 * 3600).unwrap(); 8 | 9 | /// Radiko time utilities 10 | #[derive(Debug, Clone, Copy)] 11 | pub struct RadikoTime(DateTime); 12 | 13 | impl RadikoTime { 14 | pub fn now() -> Self { 15 | Self(chrono::Utc::now().with_timezone(&JST)) 16 | } 17 | 18 | pub fn from_timestamp(timestamp: i64) -> Self { 19 | let dt = DateTime::from_timestamp(timestamp, 0) 20 | .unwrap_or_else(|| DateTime::from_timestamp(0, 0).unwrap()) 21 | .with_timezone(&JST); 22 | Self(dt) 23 | } 24 | 25 | pub fn from_timestring(timestring: &str) -> Result { 26 | // Format: YYYYMMDDHHmmss 27 | let dt = NaiveDateTime::parse_from_str(timestring, "%Y%m%d%H%M%S")?; 28 | Ok(Self(JST.from_local_datetime(&dt).unwrap())) 29 | } 30 | 31 | pub fn timestring(&self) -> String { 32 | self.0.format("%Y%m%d%H%M%S").to_string() 33 | } 34 | 35 | pub fn isoformat(&self) -> String { 36 | self.0.to_rfc3339() 37 | } 38 | 39 | pub fn timestamp(&self) -> i64 { 40 | self.0.timestamp() 41 | } 42 | 43 | /// Get the broadcast day start (5:00 AM on the current or previous day) 44 | pub fn broadcast_day_start(&self) -> Self { 45 | let mut dt = self.0; 46 | if dt.hour() < 5 { 47 | dt -= Duration::days(1); 48 | } 49 | let date = dt.date_naive(); 50 | let start = date.and_hms_opt(5, 0, 0).unwrap(); 51 | Self(JST.from_local_datetime(&start).unwrap()) 52 | } 53 | 54 | /// Get the broadcast day string (YYYYMMDD) 55 | pub fn broadcast_day_string(&self) -> String { 56 | self.broadcast_day_start().0.format("%Y%m%d").to_string() 57 | } 58 | 59 | /// Calculate expiry times for timefree content 60 | /// Returns (expiry_free, expiry_tf30) 61 | pub fn expiry(&self) -> (DateTime, DateTime) { 62 | let expiry_free = self.0 + Duration::days(7); 63 | let expiry_tf30 = self.0 + Duration::days(30); 64 | (expiry_free, expiry_tf30) 65 | } 66 | 67 | pub fn inner(&self) -> DateTime { 68 | self.0 69 | } 70 | } 71 | 72 | impl Add for RadikoTime { 73 | type Output = Self; 74 | 75 | fn add(self, duration: StdDuration) -> Self::Output { 76 | Self(self.0 + duration) 77 | } 78 | } 79 | 80 | impl AddAssign for RadikoTime { 81 | fn add_assign(&mut self, duration: StdDuration) { 82 | self.0 += duration; 83 | } 84 | } 85 | 86 | impl From> for RadikoTime { 87 | fn from(dt: DateTime) -> Self { 88 | Self(dt) 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /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(#[from] mp4decrypt::Error), 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(#[from] iori_hls::M3u8ParseError), 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(Box), 44 | 45 | // MPEG-DASH errors 46 | #[error(transparent)] 47 | MpdParseError(Box), 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(Box), 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 | #[error("{0}")] 87 | Custom(#[from] Box), 88 | } 89 | 90 | #[cfg(feature = "opendal")] 91 | impl From for IoriError { 92 | fn from(err: opendal::Error) -> Self { 93 | IoriError::OpendalError(Box::new(err)) 94 | } 95 | } 96 | 97 | impl From for IoriError { 98 | fn from(err: dash_mpd::DashMpdError) -> Self { 99 | IoriError::MpdParseError(Box::new(err)) 100 | } 101 | } 102 | 103 | impl From for IoriError { 104 | fn from(err: reqwest::Error) -> Self { 105 | IoriError::RequestError(Box::new(err)) 106 | } 107 | } 108 | 109 | pub type IoriResult = Result; 110 | -------------------------------------------------------------------------------- /crates/iori/src/cache/file.rs: -------------------------------------------------------------------------------- 1 | use super::{CacheSource, CacheSourceReader, CacheSourceWriter}; 2 | use crate::{IoriError, error::IoriResult, util::path::IoriPathExt}; 3 | use sanitize_filename_reader_friendly::sanitize; 4 | use std::path::PathBuf; 5 | use tokio::fs::File; 6 | 7 | pub struct FileCacheSource { 8 | cache_dir: PathBuf, 9 | } 10 | 11 | impl FileCacheSource { 12 | pub fn new(cache_dir: PathBuf) -> IoriResult { 13 | if cache_dir.exists() { 14 | return Err(IoriError::CacheDirExists(cache_dir)); 15 | } 16 | 17 | Ok(Self { cache_dir }) 18 | } 19 | 20 | async fn ensure_cache_dir(&self) -> IoriResult<()> { 21 | if !self.cache_dir.exists() { 22 | tokio::fs::create_dir_all(&self.cache_dir).await?; 23 | } 24 | 25 | Ok(()) 26 | } 27 | 28 | fn segment_path(&self, segment: &crate::SegmentInfo) -> PathBuf { 29 | let filename = sanitize(&segment.file_name); 30 | let stream_id = segment.stream_id; 31 | let sequence = segment.sequence; 32 | let filename = format!("{stream_id:02}_{sequence:06}_{filename}"); 33 | self.cache_dir.join(filename) 34 | } 35 | } 36 | 37 | impl CacheSource for FileCacheSource { 38 | async fn open_writer( 39 | &self, 40 | segment: &crate::SegmentInfo, 41 | ) -> IoriResult> { 42 | self.ensure_cache_dir().await?; 43 | 44 | let path = self.segment_path(segment); 45 | if path.non_empty_file_exists() { 46 | tracing::warn!("File {} already exists, ignoring.", path.display()); 47 | return Ok(None); 48 | } 49 | 50 | let tmp_file: File = File::create(path).await?; 51 | Ok(Some(Box::new(tmp_file))) 52 | } 53 | 54 | async fn open_reader(&self, segment: &crate::SegmentInfo) -> IoriResult { 55 | let path = self.segment_path(segment); 56 | let file = File::open(path).await?; 57 | Ok(Box::new(file)) 58 | } 59 | 60 | async fn segment_path(&self, segment: &crate::SegmentInfo) -> Option { 61 | Some(self.segment_path(segment)) 62 | } 63 | 64 | async fn invalidate(&self, segment: &crate::SegmentInfo) -> IoriResult<()> { 65 | let path = self.segment_path(segment); 66 | if path.exists() { 67 | tokio::fs::remove_file(path).await?; 68 | } 69 | Ok(()) 70 | } 71 | 72 | async fn clear(&self) -> IoriResult<()> { 73 | let mut entries = tokio::fs::read_dir(&self.cache_dir).await?; 74 | while let Some(entry) = entries.next_entry().await? { 75 | if entry.file_type().await?.is_dir() { 76 | tracing::warn!( 77 | "Subdirectory {} detected in cache directory. Skipping cleanup. You can remove it manually at {}", 78 | entry.path().display(), 79 | self.cache_dir.display() 80 | ); 81 | return Ok(()); 82 | } 83 | } 84 | 85 | tokio::fs::remove_dir_all(&self.cache_dir).await?; 86 | Ok(()) 87 | } 88 | 89 | fn location_hint(&self) -> Option { 90 | Some(self.cache_dir.display().to_string()) 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /crates/iori-hls/src/parse.rs: -------------------------------------------------------------------------------- 1 | use crate::{M3u8ParseError, models::*}; 2 | use quick_m3u8::{ 3 | HlsLine, Reader, 4 | config::ParsingOptionsBuilder, 5 | tag::{KnownTag, hls}, 6 | }; 7 | 8 | pub fn parse_playlist_res(input: &[u8]) -> Result { 9 | let options = ParsingOptionsBuilder::new() 10 | .with_parsing_for_all_tags() 11 | .build(); 12 | let mut reader = Reader::from_bytes(input, options); 13 | 14 | let mut is_master = false; 15 | 16 | // master playlist 17 | let mut variants = Vec::new(); 18 | let mut alternatives = Vec::new(); 19 | 20 | // media playlist 21 | let mut media_sequence = 0; 22 | let mut segments: Vec = Vec::new(); 23 | let mut end_list = false; 24 | 25 | // Maps(initial segment information) and keys(encryption information) 26 | let mut current_key: Option = None; 27 | let mut current_map: Option = None; 28 | 29 | // Pending tags, which should be cleared after the URI line is processed 30 | let mut pending_inf: Option = None; 31 | let mut pending_byterange: Option = None; 32 | let mut pending_stream_inf: Option = None; 33 | 34 | while let Some(line) = reader.read_line()? { 35 | match line { 36 | HlsLine::KnownTag(KnownTag::Hls(tag)) => match tag { 37 | hls::Tag::MediaSequence(seq) => media_sequence = seq.media_sequence(), 38 | hls::Tag::Inf(inf) => pending_inf = Some(inf), 39 | hls::Tag::Byterange(range) => pending_byterange = Some(range.into()), 40 | hls::Tag::Key(key) => current_key = Some(key.into()), 41 | hls::Tag::Map(map) => { 42 | current_map = Some((map, ¤t_key).into()); 43 | } 44 | hls::Tag::StreamInf(info) => { 45 | is_master = true; 46 | pending_stream_inf = Some(info); 47 | } 48 | hls::Tag::Media(media) => { 49 | is_master = true; 50 | alternatives.push(media.into()); 51 | } 52 | hls::Tag::Endlist(_) => end_list = true, 53 | _ => {} 54 | }, 55 | HlsLine::Uri(uri) => { 56 | if let Some(info) = pending_stream_inf.take() { 57 | variants.push((info, uri).into()); 58 | } else if let Some(inf) = pending_inf.take() { 59 | segments.push( 60 | ( 61 | inf, 62 | uri, 63 | pending_byterange.take(), 64 | current_key.clone(), 65 | current_map.clone(), 66 | ) 67 | .into(), 68 | ); 69 | } 70 | 71 | pending_inf = None; 72 | pending_byterange = None; 73 | } 74 | _ => {} 75 | } 76 | } 77 | 78 | Ok(if is_master { 79 | Playlist::MasterPlaylist(MasterPlaylist { 80 | variants, 81 | alternatives, 82 | }) 83 | } else { 84 | Playlist::MediaPlaylist(MediaPlaylist { 85 | media_sequence, 86 | segments, 87 | end_list, 88 | }) 89 | }) 90 | } 91 | -------------------------------------------------------------------------------- /platforms/radiko/src/model.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use url::Url; 3 | 4 | #[derive(Debug, Clone, Serialize, Deserialize)] 5 | pub struct StationInfo { 6 | pub id: String, 7 | pub name: String, 8 | pub ascii_name: String, 9 | pub href: Option, 10 | } 11 | 12 | // XML response structures for deserialization 13 | #[derive(Debug, Deserialize)] 14 | pub struct StationRegionResponse { 15 | #[serde(rename = "stations", default)] 16 | pub regions: Vec, 17 | } 18 | 19 | #[derive(Debug, Deserialize)] 20 | pub struct StationRegion { 21 | #[serde(rename = "station", default)] 22 | pub stations: Vec, 23 | } 24 | 25 | #[derive(Debug, Deserialize)] 26 | pub struct StationRegionItem { 27 | pub id: String, 28 | pub area_id: String, 29 | } 30 | 31 | #[derive(Debug, Deserialize)] 32 | pub struct StationListResponse { 33 | #[serde(rename = "station", default)] 34 | pub stations: Vec, 35 | } 36 | 37 | #[derive(Debug, Deserialize)] 38 | pub struct StationListItem { 39 | pub id: String, 40 | pub name: String, 41 | pub ascii_name: String, 42 | #[serde(default)] 43 | pub href: Option, 44 | } 45 | 46 | #[derive(Debug, Deserialize)] 47 | pub struct StreamResponse { 48 | #[serde(rename = "url", default)] 49 | pub urls: Vec, 50 | } 51 | 52 | #[derive(Debug, Deserialize)] 53 | pub struct StreamUrlItem { 54 | #[serde(rename = "@timefree")] 55 | pub timefree: String, 56 | #[serde(rename = "@areafree")] 57 | pub areafree: String, 58 | pub playlist_create_url: Option, 59 | } 60 | 61 | #[derive(Debug, Clone, Serialize, Deserialize)] 62 | pub struct StreamUrl { 63 | pub url: Url, 64 | pub timefree: bool, 65 | } 66 | 67 | #[derive(Debug, Clone)] 68 | pub struct AuthData { 69 | pub auth_token: String, 70 | pub area_id: String, 71 | pub user_id: String, 72 | } 73 | 74 | #[derive(Debug, Clone, Serialize, Deserialize)] 75 | pub struct ProgrammeInfo { 76 | pub station_id: String, 77 | pub title: String, 78 | pub start_time: String, 79 | pub end_time: String, 80 | pub duration: u64, 81 | pub ft: String, 82 | pub to: String, 83 | pub performer: Option, 84 | pub description: Option, 85 | pub img: Option, 86 | } 87 | 88 | // JSON response structures for programme data 89 | #[derive(Debug, Deserialize)] 90 | pub struct ProgrammeResponse { 91 | pub stations: Vec, 92 | } 93 | 94 | #[derive(Debug, Deserialize)] 95 | pub struct ProgrammeStation { 96 | pub programs: ProgrammePrograms, 97 | } 98 | 99 | #[derive(Debug, Deserialize)] 100 | pub struct ProgrammePrograms { 101 | pub program: Vec, 102 | } 103 | 104 | #[derive(Debug, Deserialize)] 105 | pub struct ProgrammeItem { 106 | pub title: String, 107 | pub ft: String, 108 | pub to: String, 109 | pub dur: Option, 110 | #[serde(default)] 111 | pub performer: Option, 112 | #[serde(default)] 113 | pub info: Option, 114 | #[serde(default)] 115 | pub img: Option, 116 | } 117 | 118 | #[derive(Debug, Clone)] 119 | pub struct DeviceInfo { 120 | pub app: String, 121 | pub app_version: String, 122 | pub device: String, 123 | pub user_id: String, 124 | pub user_agent: String, 125 | } 126 | -------------------------------------------------------------------------------- /crates/iori/src/cache/opendal.rs: -------------------------------------------------------------------------------- 1 | use super::{CacheSource, CacheSourceReader, CacheSourceWriter}; 2 | use crate::error::IoriResult; 3 | use sanitize_filename_reader_friendly::sanitize; 4 | use std::path::PathBuf; 5 | use tokio_util::compat::{FuturesAsyncReadCompatExt, FuturesAsyncWriteCompatExt}; 6 | 7 | pub use opendal::*; 8 | 9 | pub struct OpendalCacheSource { 10 | operator: Operator, 11 | prefix: String, 12 | content_type: Option, 13 | 14 | with_internal_prefix: bool, 15 | } 16 | 17 | impl OpendalCacheSource { 18 | pub fn new( 19 | operator: Operator, 20 | prefix: impl Into, 21 | with_internal_prefix: bool, 22 | content_type: Option, 23 | ) -> Self { 24 | Self { 25 | operator, 26 | prefix: prefix.into(), 27 | content_type, 28 | with_internal_prefix, 29 | } 30 | } 31 | 32 | fn segment_key(&self, segment: &crate::SegmentInfo) -> String { 33 | let prefix = &self.prefix; 34 | let filename = sanitize(&segment.file_name); 35 | if self.with_internal_prefix { 36 | let stream_id = segment.stream_id; 37 | let sequence = segment.sequence; 38 | format!("{prefix}/{stream_id:02}_{sequence:06}_{filename}") 39 | } else { 40 | format!("{prefix}/{filename}") 41 | } 42 | } 43 | } 44 | 45 | impl CacheSource for OpendalCacheSource { 46 | async fn open_writer( 47 | &self, 48 | segment: &crate::SegmentInfo, 49 | ) -> IoriResult> { 50 | let key = self.segment_key(segment); 51 | 52 | if self.operator.exists(&key).await? { 53 | tracing::warn!("File {} already exists, ignoring.", key); 54 | return Ok(None); 55 | } 56 | 57 | let mut writer = self.operator.writer_with(&key); 58 | if let Some(content_type) = &self.content_type { 59 | writer = writer.content_type(content_type); 60 | } 61 | let writer = writer 62 | .chunk(5 * 1024 * 1024) 63 | .await? 64 | .into_futures_async_write() 65 | .compat_write(); 66 | Ok(Some(Box::new(writer))) 67 | } 68 | 69 | async fn open_reader(&self, segment: &crate::SegmentInfo) -> IoriResult { 70 | let key = self.segment_key(segment); 71 | let stat = self.operator.stat(&key).await?; 72 | let length = stat.content_length(); 73 | let reader = self 74 | .operator 75 | .reader(&key) 76 | .await? 77 | .into_futures_async_read(0..length) 78 | .await? 79 | .compat(); 80 | 81 | Ok(Box::new(reader)) 82 | } 83 | 84 | async fn segment_path(&self, segment: &crate::SegmentInfo) -> Option { 85 | Some(PathBuf::from(self.segment_key(segment))) 86 | } 87 | 88 | async fn invalidate(&self, segment: &crate::SegmentInfo) -> IoriResult<()> { 89 | let key = self.segment_key(segment); 90 | self.operator.delete(&key).await?; 91 | Ok(()) 92 | } 93 | 94 | async fn clear(&self) -> IoriResult<()> { 95 | self.operator.remove_all(&self.prefix).await?; 96 | Ok(()) 97 | } 98 | 99 | fn location_hint(&self) -> Option { 100 | Some(self.prefix.clone()) 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /crates/iori/src/fetch.rs: -------------------------------------------------------------------------------- 1 | use reqwest::header::RANGE; 2 | use tokio::io::AsyncWriteExt; 3 | 4 | use crate::{ 5 | InitialSegment, RemoteStreamingSegment, StreamingSegment, WriteSegment, 6 | context::IoriContext, 7 | error::{IoriError, IoriResult}, 8 | }; 9 | 10 | pub trait SegmentToBytes { 11 | fn to_bytes( 12 | &self, 13 | context: &IoriContext, 14 | ) -> impl Future> + Send; 15 | } 16 | 17 | impl SegmentToBytes for T 18 | where 19 | T: RemoteStreamingSegment, 20 | { 21 | fn to_bytes( 22 | &self, 23 | context: &IoriContext, 24 | ) -> impl Future> + Send { 25 | let url = self.url(); 26 | let byte_range = self.byte_range(); 27 | let headers = self.headers(); 28 | async move { 29 | let mut request = context.client.get(url); 30 | if let Some(headers) = headers { 31 | request = request.headers(headers); 32 | } 33 | if let Some(byte_range) = byte_range { 34 | request = request.header(RANGE, byte_range.to_http_range()); 35 | } 36 | let response = request.send().await?; 37 | if !response.status().is_success() { 38 | let status = response.status(); 39 | if let Ok(body) = response.text().await { 40 | tracing::warn!("Error body: {body}"); 41 | } 42 | return Err(IoriError::HttpError(status)); 43 | } 44 | 45 | let bytes = response.bytes().await?; 46 | Ok(bytes) 47 | } 48 | } 49 | } 50 | 51 | impl WriteSegment for T 52 | where 53 | T: StreamingSegment + SegmentToBytes + Sync, 54 | { 55 | async fn write_segment(&self, context: &IoriContext, writer: &mut W) -> IoriResult<()> 56 | where 57 | W: tokio::io::AsyncWrite + Unpin + Send, 58 | { 59 | let bytes = self.to_bytes(context).await?; 60 | 61 | // TODO: use bytes_stream to improve performance 62 | // .bytes_stream(); 63 | let decryptor = self.key().map(|key| { 64 | key.to_decryptor( 65 | self.format(), 66 | context.shaka_packager_command.as_ref().to_owned(), 67 | ) 68 | }); 69 | if let Some(decryptor) = decryptor { 70 | let decrypted_bytes = match self.initial_segment() { 71 | crate::InitialSegment::Encrypted(data) => { 72 | let mut result = data.to_vec(); 73 | result.extend_from_slice(&bytes); 74 | decryptor.decrypt(&result).await? 75 | } 76 | crate::InitialSegment::Clear(data) => { 77 | writer.write_all(&data).await?; 78 | decryptor.decrypt(&bytes).await? 79 | } 80 | crate::InitialSegment::None => decryptor.decrypt(&bytes).await?, 81 | }; 82 | writer.write_all(&decrypted_bytes).await?; 83 | } else { 84 | // If no key is provided, no matter whether the initial segment is encrypted or not, 85 | // we should write the initial segment to the file. 86 | if let InitialSegment::Clear(initial_segment) 87 | | InitialSegment::Encrypted(initial_segment) = self.initial_segment() 88 | { 89 | writer.write_all(&initial_segment).await?; 90 | } 91 | writer.write_all(&bytes).await?; 92 | } 93 | writer.flush().await?; 94 | 95 | Ok(()) 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /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 | SegmentFormat, SegmentInfo, 7 | cache::CacheSource, 8 | merge::{IoriMerger, Merger}, 9 | }; 10 | use tokio::{ 11 | fs::{File, read_dir}, 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, false) 86 | } else { 87 | IoriMerger::mkvmerge(me.output, false)? 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 | -------------------------------------------------------------------------------- /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 | use crate::context::IoriContext; 13 | pub use crate::util::http::HttpClient; 14 | pub use futures::Stream; 15 | pub use reqwest; 16 | pub mod utils { 17 | pub use crate::util::detect_manifest_type; 18 | pub use crate::util::path::DuplicateOutputFileNamer; 19 | pub use crate::util::path::sanitize; 20 | } 21 | 22 | pub mod context; 23 | 24 | mod segment; 25 | pub use segment::*; 26 | mod error; 27 | pub use error::*; 28 | pub use util::range::ByteRange; 29 | 30 | /// ┌───────────────────────┐ ┌────────────────────┐ 31 | /// │ │ Segment 1 │ │ 32 | /// │ ├────────────────► ├───┐ 33 | /// │ │ │ │ │fetch_segment 34 | /// │ │ Segment 2 │ ◄───┘ 35 | /// │ M3U8 Time#1 ├────────────────► Downloader │ 36 | /// │ │ │ ├───┐ 37 | /// │ │ Segment 3 │ [MPSC] │ │fetch_segment 38 | /// │ ├────────────────► ◄───┘ 39 | /// │ │ │ │ 40 | /// └───────────────────────┘ │ ├───┐ 41 | /// │ │ │fetch_segment 42 | /// ┌───────────────────────┐ │ ◄───┘ 43 | /// │ │ ... │ │ 44 | /// │ ├────────────────► │ 45 | /// │ │ │ │ 46 | /// │ M3U8 Time#N │ │ │ 47 | /// │ │ │ │ 48 | /// │ │ │ │ 49 | /// │ │ Segment Last │ │ 50 | /// │ ├────────────────► │ 51 | /// └───────────────────────┘ └────────────────────┘ 52 | pub trait StreamingSource { 53 | type Segment: StreamingSegment + WriteSegment + Send + 'static; 54 | 55 | fn segments_stream( 56 | &self, 57 | context: &IoriContext, 58 | ) -> impl Future>>>>; 59 | } 60 | 61 | pub trait StreamingSegment { 62 | /// Stream id 63 | fn stream_id(&self) -> u64; 64 | 65 | /// Stream type 66 | fn stream_type(&self) -> StreamType; 67 | 68 | /// Sequence ID of the segment, starts from 0 69 | fn sequence(&self) -> u64; 70 | 71 | /// File name of the segment 72 | fn file_name(&self) -> &str; 73 | 74 | /// Optional initial segment data 75 | fn initial_segment(&self) -> InitialSegment { 76 | InitialSegment::None 77 | } 78 | 79 | /// Optional key for decryption 80 | fn key(&self) -> Option>; 81 | 82 | /// Optional duration of the segment 83 | fn duration(&self) -> Option { 84 | None 85 | } 86 | 87 | /// Format hint for the segment 88 | fn format(&self) -> SegmentFormat; 89 | } 90 | 91 | pub trait WriteSegment { 92 | fn write_segment( 93 | &self, 94 | context: &IoriContext, 95 | writer: &mut W, 96 | ) -> impl Future> + Send 97 | where 98 | W: tokio::io::AsyncWrite + Unpin + Send; 99 | } 100 | -------------------------------------------------------------------------------- /crates/iori/src/download/app.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | num::NonZeroU32, 3 | sync::atomic::{AtomicUsize, Ordering}, 4 | }; 5 | 6 | use futures::lock::Mutex; 7 | 8 | use crate::{IoriResult, SegmentInfo}; 9 | 10 | pub trait DownloaderApp { 11 | fn on_start(&self) -> impl Future> + Send; 12 | 13 | fn on_receive_segments(&self, segments: &[SegmentInfo]) -> impl Future + Send; 14 | 15 | fn on_downloaded_segment(&self, segment: &SegmentInfo) -> impl Future + Send; 16 | 17 | fn on_failed_segment(&self, segment: &SegmentInfo) -> impl Future + Send; 18 | 19 | fn on_finished(&self) -> impl Future> + Send; 20 | } 21 | 22 | impl DownloaderApp for () { 23 | async fn on_start(&self) -> IoriResult<()> { 24 | Ok(()) 25 | } 26 | 27 | async fn on_receive_segments(&self, _segments: &[SegmentInfo]) {} 28 | 29 | async fn on_downloaded_segment(&self, _segment: &SegmentInfo) {} 30 | 31 | async fn on_failed_segment(&self, _segment: &SegmentInfo) {} 32 | 33 | async fn on_finished(&self) -> IoriResult<()> { 34 | Ok(()) 35 | } 36 | } 37 | 38 | #[derive(Default)] 39 | pub struct TracingApp { 40 | concurrency: Option, 41 | 42 | total: AtomicUsize, 43 | downloaded: AtomicUsize, 44 | failed: AtomicUsize, 45 | failed_segments_name: Mutex>, 46 | } 47 | 48 | impl TracingApp { 49 | pub fn concurrent(concurrency: NonZeroU32) -> Self { 50 | Self { 51 | concurrency: Some(concurrency), 52 | ..Default::default() 53 | } 54 | } 55 | } 56 | 57 | impl DownloaderApp for TracingApp { 58 | async fn on_start(&self) -> IoriResult<()> { 59 | if let Some(concurrency) = self.concurrency { 60 | tracing::info!("Start downloading with {} thread(s).", concurrency.get()); 61 | } else { 62 | tracing::info!("Start downloading."); 63 | } 64 | Ok(()) 65 | } 66 | 67 | async fn on_receive_segments(&self, segments: &[SegmentInfo]) { 68 | self.total.fetch_add(segments.len(), Ordering::Relaxed); 69 | tracing::info!("{} new segments were added to queue.", segments.len()); 70 | } 71 | 72 | async fn on_downloaded_segment(&self, segment: &SegmentInfo) { 73 | let filename = &segment.file_name; 74 | let downloaded = self.downloaded.fetch_add(1, Ordering::Relaxed) 75 | + 1 76 | + self.failed.load(Ordering::Relaxed); 77 | let total = self.total.load(Ordering::Relaxed); 78 | let percentage = if total == 0 { 79 | 0. 80 | } else { 81 | downloaded as f32 / total as f32 * 100. 82 | }; 83 | tracing::info!( 84 | "Processing {filename} finished. ({downloaded} / {total} or {percentage:.2}%)" 85 | ); 86 | } 87 | 88 | async fn on_failed_segment(&self, segment: &SegmentInfo) { 89 | let filename = &segment.file_name; 90 | 91 | self.failed_segments_name 92 | .lock() 93 | .await 94 | .push(filename.to_string()); 95 | self.failed.fetch_add(1, Ordering::Relaxed); 96 | 97 | tracing::error!("Processing {filename} failed, max retries exceed, drop."); 98 | } 99 | 100 | async fn on_finished(&self) -> IoriResult<()> { 101 | let failed = self.failed_segments_name.lock().await; 102 | if !failed.is_empty() { 103 | tracing::error!("Failed to download {} segments:", failed.len()); 104 | for segment in failed.iter() { 105 | tracing::error!(" - {}", segment); 106 | } 107 | } 108 | Ok(()) 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /crates/iori/tests/streaming.rs: -------------------------------------------------------------------------------- 1 | use iori::decrypt::IoriKey; 2 | use iori::{InitialSegment, SegmentFormat, StreamType, 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 | stream_type: StreamType, 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 | stream_type: StreamType, 23 | format: SegmentFormat, 24 | ) -> Self { 25 | Self { 26 | stream_id, 27 | sequence, 28 | file_name, 29 | initial_segment, 30 | key, 31 | stream_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 stream_type(&self) -> StreamType { 59 | self.stream_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 | StreamType::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.stream_type(), StreamType::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 | StreamType::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 | StreamType::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/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 | && let Some(item) = stream_buffer.remove(next_seq) 28 | { 29 | *next_seq += 1; 30 | return Some((*stream_id, item)); 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/merge/concat.rs: -------------------------------------------------------------------------------- 1 | use super::Merger; 2 | use crate::{ 3 | SegmentInfo, 4 | cache::CacheSource, 5 | error::IoriResult, 6 | util::path::{DuplicateOutputFileNamer, IoriPathExt}, 7 | }; 8 | use std::path::PathBuf; 9 | use tokio::fs::File; 10 | 11 | /// Concat all segments into a single file after all segments are downloaded. 12 | pub struct ConcatAfterMerger { 13 | segments: Vec, 14 | 15 | /// Final output file path. 16 | output_file: PathBuf, 17 | /// Whether to recycle downloaded segments after merging. 18 | recycle: bool, 19 | } 20 | 21 | impl ConcatAfterMerger { 22 | pub fn new(output_file: PathBuf, recycle: bool) -> Self { 23 | Self { 24 | segments: Vec::new(), 25 | output_file, 26 | recycle, 27 | } 28 | } 29 | } 30 | 31 | impl Merger for ConcatAfterMerger { 32 | type Result = (); 33 | 34 | async fn update(&mut self, segment: SegmentInfo, _cache: impl CacheSource) -> IoriResult<()> { 35 | self.segments.push(ConcatSegment { 36 | segment, 37 | success: true, 38 | }); 39 | Ok(()) 40 | } 41 | 42 | async fn fail(&mut self, segment: SegmentInfo, cache: impl CacheSource) -> IoriResult<()> { 43 | cache.invalidate(&segment).await?; 44 | self.segments.push(ConcatSegment { 45 | segment, 46 | success: false, 47 | }); 48 | Ok(()) 49 | } 50 | 51 | async fn finish(&mut self, cache: impl CacheSource) -> IoriResult { 52 | tracing::info!("Merging chunks..."); 53 | concat_merge( 54 | &mut self.segments, 55 | &cache, 56 | self.output_file.clone().sanitize().deduplicate()?, 57 | ) 58 | .await?; 59 | 60 | if self.recycle { 61 | tracing::info!("End of merging."); 62 | tracing::info!("Starting cleaning temporary files."); 63 | cache.clear().await?; 64 | } 65 | 66 | tracing::info!( 67 | "All finished. Please checkout your files at {}", 68 | self.output_file.display() 69 | ); 70 | Ok(()) 71 | } 72 | } 73 | 74 | fn trim_end(input: &[T], should_skip: fn(&T) -> bool) -> &[T] { 75 | let mut end = input.len(); 76 | while end > 0 && should_skip(&input[end - 1]) { 77 | end -= 1; 78 | } 79 | &input[..end] 80 | } 81 | 82 | pub(crate) struct ConcatSegment { 83 | pub segment: SegmentInfo, 84 | pub success: bool, 85 | } 86 | 87 | async fn concat_merge( 88 | segments: &mut [ConcatSegment], 89 | cache: &impl CacheSource, 90 | output_path: PathBuf, 91 | ) -> IoriResult<()> { 92 | segments.sort_by(|a, b| a.segment.sequence.cmp(&b.segment.sequence)); 93 | let segments = trim_end(segments, |s| !s.success); 94 | 95 | let mut namer = DuplicateOutputFileNamer::new(output_path.clone()); 96 | let mut output = File::create(output_path).await?; 97 | for segment in segments { 98 | let success = segment.success; 99 | let segment = &segment.segment; 100 | if !success { 101 | output = File::create(namer.next_path()).await?; 102 | } 103 | 104 | let mut reader = cache.open_reader(segment).await?; 105 | tokio::io::copy(&mut reader, &mut output).await?; 106 | } 107 | Ok(()) 108 | } 109 | 110 | #[cfg(test)] 111 | mod tests { 112 | #[test] 113 | fn test_trim_end() { 114 | let input = [1, 2, 3, 0, 0, 0, 0, 0, 0, 0, 0]; 115 | let output = super::trim_end(&input, |&x| x == 0); 116 | assert_eq!(output, [1, 2, 3]); 117 | 118 | let input = [0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2, 3]; 119 | let output = super::trim_end(&input, |&x| x == 0); 120 | assert_eq!(output, input); 121 | 122 | let input = [1, 2, 3, 0, 0, 3, 0, 0, 0]; 123 | let output = super::trim_end(&input, |&x| x == 0); 124 | assert_eq!(output, [1, 2, 3, 0, 0, 3]); 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /crates/iori/src/dash/live.rs: -------------------------------------------------------------------------------- 1 | mod clock; 2 | mod selector; 3 | mod timeline; 4 | 5 | use super::segment::DashSegment; 6 | use crate::{IoriResult, StreamingSource, context::IoriContext, decrypt::IoriKey}; 7 | use futures::{Stream, stream}; 8 | use std::{ 9 | sync::{ 10 | Arc, 11 | atomic::{AtomicU64, Ordering}, 12 | }, 13 | time::Duration, 14 | }; 15 | use timeline::MPDTimeline; 16 | use tokio::sync::{Mutex, mpsc}; 17 | use url::Url; 18 | 19 | pub struct CommonDashLiveSource { 20 | mpd_url: Url, 21 | key: Option>, 22 | timeline: Arc>>, 23 | } 24 | 25 | impl CommonDashLiveSource { 26 | pub fn new(mpd_url: Url, key: Option<&str>) -> IoriResult { 27 | let key = key.map(IoriKey::clear_key).transpose()?.map(Arc::new); 28 | 29 | Ok(Self { 30 | mpd_url, 31 | key, 32 | timeline: Arc::new(Mutex::new(None)), 33 | }) 34 | } 35 | } 36 | 37 | impl StreamingSource for CommonDashLiveSource { 38 | type Segment = DashSegment; 39 | 40 | async fn segments_stream( 41 | &self, 42 | context: &IoriContext, 43 | ) -> IoriResult>>> { 44 | let (sender, receiver) = mpsc::unbounded_channel(); 45 | 46 | let mpd = context 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(&context.client, mpd, Some(&self.mpd_url)).await?; 59 | 60 | let (mut segments, mut last_update) = timeline 61 | .segments_since(&context.client, None, self.key.clone()) 62 | .await?; 63 | for segment in segments.iter_mut() { 64 | segment.sequence = sequence_number.fetch_add(1, Ordering::Relaxed); 65 | } 66 | sender.send(Ok(segments)).unwrap(); 67 | 68 | if timeline.is_dynamic() { 69 | self.timeline.lock().await.replace(timeline); 70 | 71 | let mpd_url = self.mpd_url.clone(); 72 | let client = context.client.clone(); 73 | let timeline = self.timeline.clone(); 74 | let key = self.key.clone(); 75 | tokio::spawn(async move { 76 | loop { 77 | tokio::time::sleep(minimum_update_period).await; 78 | 79 | let mpd = client 80 | .get(mpd_url.as_ref()) 81 | .send() 82 | .await 83 | .unwrap() 84 | .text() 85 | .await 86 | .unwrap(); 87 | let mpd = dash_mpd::parse(&mpd).unwrap(); 88 | 89 | let mut timeline = timeline.lock().await; 90 | let timeline = timeline.as_mut().unwrap(); 91 | timeline.update_mpd(&client, mpd, &mpd_url).await.unwrap(); 92 | 93 | let (segments, _last_update) = timeline 94 | .segments_since(&client, last_update, key.clone()) 95 | .await 96 | .unwrap(); 97 | sender.send(Ok(segments)).unwrap(); 98 | 99 | if let Some(_last_update) = _last_update { 100 | last_update = Some(_last_update); 101 | } 102 | 103 | if timeline.is_static() { 104 | break; 105 | } 106 | } 107 | }); 108 | } 109 | 110 | Ok(Box::pin(stream::unfold(receiver, |mut receiver| async { 111 | receiver.recv().await.map(|item| (item, receiver)) 112 | }))) 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /crates/iori/src/hls/archive.rs: -------------------------------------------------------------------------------- 1 | use std::{num::ParseIntError, str::FromStr, sync::Arc}; 2 | 3 | use futures::{Stream, stream}; 4 | use tokio::sync::Mutex; 5 | use url::Url; 6 | 7 | use crate::{ 8 | StreamingSource, 9 | context::IoriContext, 10 | error::IoriResult, 11 | hls::{segment::M3u8Segment, source::HlsPlaylistSource}, 12 | }; 13 | 14 | pub struct CommonM3u8ArchiveSource { 15 | playlist: Arc>, 16 | range: SegmentRange, 17 | } 18 | 19 | /// A subrange for m3u8 archive sources to choose which segment to use 20 | #[derive(Debug, Clone, Copy)] 21 | pub struct SegmentRange { 22 | /// Start offset to use. Default to 1 23 | pub start: u64, 24 | /// End offset to use. Default to None 25 | pub end: Option, 26 | } 27 | 28 | impl Default for SegmentRange { 29 | fn default() -> Self { 30 | Self { 31 | start: 1, 32 | end: None, 33 | } 34 | } 35 | } 36 | 37 | impl SegmentRange { 38 | pub fn new(start: u64, end: Option) -> Self { 39 | Self { start, end } 40 | } 41 | 42 | pub fn end(&self) -> u64 { 43 | self.end.unwrap_or(u64::MAX) 44 | } 45 | } 46 | 47 | impl FromStr for SegmentRange { 48 | type Err = ParseIntError; 49 | 50 | fn from_str(s: &str) -> Result { 51 | let (start, end) = s.split_once('-').unwrap_or((s, "")); 52 | let start = if start.is_empty() { 1 } else { start.parse()? }; 53 | let end = if end.is_empty() { 54 | None 55 | } else { 56 | Some(end.parse()?) 57 | }; 58 | Ok(Self { start, end }) 59 | } 60 | } 61 | 62 | impl CommonM3u8ArchiveSource { 63 | pub fn new(playlist_url: String, key: Option<&str>, range: SegmentRange) -> IoriResult { 64 | Ok(Self { 65 | playlist: Arc::new(Mutex::new(HlsPlaylistSource::new( 66 | Url::parse(&playlist_url)?, 67 | key, 68 | ))), 69 | range, 70 | }) 71 | } 72 | } 73 | 74 | impl StreamingSource for CommonM3u8ArchiveSource { 75 | type Segment = M3u8Segment; 76 | 77 | async fn segments_stream( 78 | &self, 79 | context: &IoriContext, 80 | ) -> IoriResult>>> { 81 | let latest_media_sequences = self.playlist.lock().await.load_streams(context).await?; 82 | 83 | let (segments, _) = self 84 | .playlist 85 | .lock() 86 | .await 87 | .load_segments(context, &latest_media_sequences) 88 | .await?; 89 | let mut segments: Vec<_> = segments 90 | .into_iter() 91 | .flatten() 92 | .filter_map(|segment| { 93 | let seq = segment.sequence + 1; 94 | if seq >= self.range.start && seq <= self.range.end() { 95 | return Some(segment); 96 | } 97 | None 98 | }) 99 | .collect(); 100 | 101 | // make sequence start form 1 again 102 | for (seq, segment) in segments.iter_mut().enumerate() { 103 | segment.sequence = seq as u64; 104 | } 105 | 106 | Ok(Box::pin(stream::once(async move { Ok(segments) }))) 107 | } 108 | } 109 | 110 | #[cfg(test)] 111 | mod tests { 112 | use super::*; 113 | 114 | #[test] 115 | fn test_parse_range() { 116 | let range = "1-10".parse::().unwrap(); 117 | assert_eq!(range.start, 1); 118 | assert_eq!(range.end, Some(10)); 119 | 120 | let range = "1-".parse::().unwrap(); 121 | assert_eq!(range.start, 1); 122 | assert_eq!(range.end, None); 123 | 124 | let range = "-10".parse::().unwrap(); 125 | assert_eq!(range.start, 1); 126 | assert_eq!(range.end, Some(10)); 127 | 128 | let range = "1".parse::().unwrap(); 129 | assert_eq!(range.start, 1); 130 | assert_eq!(range.end, None); 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /crates/iori/src/hls/utils.rs: -------------------------------------------------------------------------------- 1 | use iori_hls::{MediaPlaylist, Playlist}; 2 | use reqwest::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: &HttpClient, 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 iori_hls::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::debug!("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 iori_hls::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::debug!("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 | && a.width != b.width 88 | { 89 | return b.width.cmp(&a.width); 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/hls/live.rs: -------------------------------------------------------------------------------- 1 | use futures::{Stream, stream}; 2 | use std::{sync::Arc, time::Duration}; 3 | use tokio::sync::{Mutex, mpsc}; 4 | use url::Url; 5 | 6 | use crate::{ 7 | StreamingSource, 8 | context::IoriContext, 9 | error::{IoriError, IoriResult}, 10 | hls::{segment::M3u8Segment, source::HlsPlaylistSource}, 11 | util::mix::VecMix, 12 | }; 13 | 14 | pub struct HlsLiveSource { 15 | playlist: Arc>, 16 | } 17 | 18 | impl HlsLiveSource { 19 | pub fn new(m3u8_url: String, key: Option<&str>) -> IoriResult { 20 | Ok(Self { 21 | playlist: Arc::new(Mutex::new(HlsPlaylistSource::new( 22 | Url::parse(&m3u8_url)?, 23 | key, 24 | ))), 25 | }) 26 | } 27 | } 28 | 29 | impl StreamingSource for HlsLiveSource { 30 | type Segment = M3u8Segment; 31 | 32 | async fn segments_stream( 33 | &self, 34 | context: &IoriContext, 35 | ) -> IoriResult>>> { 36 | let mut latest_media_sequences = self.playlist.lock().await.load_streams(context).await?; 37 | 38 | let (sender, receiver) = mpsc::unbounded_channel(); 39 | 40 | let playlist = self.playlist.clone(); 41 | let context = context.clone(); 42 | tokio::spawn(async move { 43 | loop { 44 | if sender.is_closed() { 45 | break; 46 | } 47 | 48 | let before_load = tokio::time::Instant::now(); 49 | let (segments, is_end) = match playlist 50 | .lock() 51 | .await 52 | .load_segments(&context, &latest_media_sequences) 53 | .await 54 | { 55 | Ok(v) => v, 56 | Err(IoriError::ManifestFetchError) => { 57 | tracing::error!("Exceeded retry limit for fetching segments, exiting..."); 58 | break; 59 | } 60 | Err(e) => { 61 | tracing::error!("Failed to fetch segments: {e}"); 62 | break; 63 | } 64 | }; 65 | 66 | let segments_average_duration = segments 67 | .iter() 68 | .map(|ss| { 69 | let total_seconds = ss.iter().map(|s| s.duration).sum::(); 70 | let segments_count = ss.len() as f64; 71 | 72 | if segments_count == 0. { 73 | 0 74 | } else { 75 | (total_seconds * 1000. / segments_count) as u64 76 | } 77 | }) 78 | .min() 79 | .unwrap_or(5); 80 | 81 | for (segments, latest_media_sequence) in 82 | segments.iter().zip(latest_media_sequences.iter_mut()) 83 | { 84 | *latest_media_sequence = segments 85 | .last() 86 | .map(|r| r.media_sequence) 87 | .or(*latest_media_sequence); 88 | } 89 | 90 | let mixed_segments = segments.mix(); 91 | if !mixed_segments.is_empty() 92 | && let Err(e) = sender.send(Ok(mixed_segments)) 93 | { 94 | tracing::error!("Failed to send mixed segments: {e}"); 95 | break; 96 | } 97 | 98 | if is_end { 99 | break; 100 | } 101 | 102 | // playlist does not end, wait for a while and fetch again 103 | let seconds_to_wait = segments_average_duration.clamp(1000, 5000); 104 | tokio::time::sleep_until(before_load + Duration::from_millis(seconds_to_wait)) 105 | .await; 106 | } 107 | }); 108 | 109 | Ok(Box::pin(stream::unfold(receiver, |mut receiver| async { 110 | receiver.recv().await.map(|item| (item, receiver)) 111 | }))) 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /plugins/plugin-showroom/src/lib.rs: -------------------------------------------------------------------------------- 1 | use iori_showroom::ShowRoomClient; 2 | use shiori_plugin::*; 3 | 4 | pub struct ShowroomPlugin; 5 | 6 | impl ShioriPlugin for ShowroomPlugin { 7 | fn name(&self) -> Cow<'static, str> { 8 | "showroom".into() 9 | } 10 | 11 | fn version(&self) -> Cow<'static, str> { 12 | "0.1.0".into() 13 | } 14 | 15 | fn description(&self) -> Option> { 16 | Some("Extracts Showroom playlists from the given URL.".into()) 17 | } 18 | 19 | fn arguments(&self, command: &mut dyn InspectorCommand) { 20 | command.add_argument( 21 | "showroom-user-session", 22 | Some("sr_id"), 23 | "[Showroom] Your Showroom user session key.", 24 | ); 25 | } 26 | 27 | fn register(&self, registry: &mut dyn InspectorRegistry) -> anyhow::Result<()> { 28 | registry.register_inspector( 29 | Regex::new(r"https://www.showroom-live.com/r/(?.*)").unwrap(), 30 | Box::new(ShowroomLiveInspector), 31 | PriorityHint::Normal, 32 | ); 33 | registry.register_inspector( 34 | Regex::new(r"https://www.showroom-live.com/timeshift/(?[^/]+)\/(?.+)").unwrap(), 35 | Box::new(ShowroomTimeshiftInspector), 36 | PriorityHint::Normal, 37 | ); 38 | 39 | Ok(()) 40 | } 41 | } 42 | 43 | struct ShowroomLiveInspector; 44 | 45 | #[async_trait] 46 | impl Inspect for ShowroomLiveInspector { 47 | fn name(&self) -> Cow<'static, str> { 48 | "showroom-live".into() 49 | } 50 | 51 | async fn inspect( 52 | &self, 53 | _url: &str, 54 | captures: &Captures, 55 | args: &dyn InspectorArguments, 56 | ) -> anyhow::Result { 57 | let client = ShowRoomClient::new(args.get_string("showroom-user-session")).await?; 58 | 59 | let room_name = captures.name("room_name").unwrap(); 60 | let room_id = match room_name.as_str().parse::() { 61 | Ok(room_id) => room_id, 62 | Err(_) => client.room_info(room_name.as_str()).await?.id, 63 | }; 64 | 65 | let info = client.live_info(room_id).await?; 66 | if !info.is_living() { 67 | return Ok(InspectResult::None); 68 | } 69 | 70 | let streams = client.live_streaming_url(room_id).await?; 71 | let Some(stream) = streams.best(false) else { 72 | return Ok(InspectResult::None); 73 | }; 74 | 75 | Ok(InspectResult::Playlist(InspectPlaylist { 76 | title: Some(info.room_name), 77 | playlist_url: stream.url.clone(), 78 | playlist_type: PlaylistType::HLS, 79 | ..Default::default() 80 | })) 81 | } 82 | } 83 | 84 | /// https://showroom-live.com/timeshift/stu48_8th_empathy_/k86763 85 | struct ShowroomTimeshiftInspector; 86 | 87 | #[async_trait] 88 | impl Inspect for ShowroomTimeshiftInspector { 89 | fn name(&self) -> Cow<'static, str> { 90 | "showroom-timeshift".into() 91 | } 92 | 93 | async fn inspect( 94 | &self, 95 | _url: &str, 96 | captures: &Captures, 97 | args: &dyn InspectorArguments, 98 | ) -> anyhow::Result { 99 | let client = ShowRoomClient::new(args.get_string("sr-id")).await?; 100 | 101 | let room_url_key = captures.name("room_url_key").unwrap(); 102 | let view_url_key = captures.name("view_url_key").unwrap(); 103 | let timeshift_info = client 104 | .timeshift_info(room_url_key.as_str(), view_url_key.as_str()) 105 | .await?; 106 | let timeshift_streaming_url = client 107 | .timeshift_streaming_url( 108 | timeshift_info.timeshift.room_id, 109 | timeshift_info.timeshift.live_id, 110 | ) 111 | .await?; 112 | let stream = timeshift_streaming_url.best(); 113 | Ok(InspectResult::Playlist(InspectPlaylist { 114 | title: Some(timeshift_info.timeshift.title), 115 | playlist_url: stream.url().to_string(), 116 | playlist_type: PlaylistType::HLS, 117 | ..Default::default() 118 | })) 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /crates/iori/tests/hls/m3u8_rs.rs: -------------------------------------------------------------------------------- 1 | // crates/iori/tests/fixtures/hls/m3u8-rs/media-playlist-with-byterange.m3u8 2 | 3 | use iori::{ByteRange, context::IoriContext, hls::HlsPlaylistSource}; 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 context = IoriContext::default(); 13 | let mut playlist = HlsPlaylistSource::new(playlist_uri.parse()?, None); 14 | 15 | let latest_media_sequences = playlist.load_streams(&context).await?; 16 | let (streams, is_end) = playlist 17 | .load_segments(&context, &latest_media_sequences) 18 | .await?; 19 | 20 | assert!(!is_end); 21 | assert_eq!(streams.len(), 1); 22 | 23 | let segments = &streams[0]; 24 | assert_eq!(segments.len(), 3); 25 | 26 | let segment = &segments[0]; 27 | assert_eq!(segment.url, format!("{}/video.ts", server.uri()).parse()?); 28 | assert_eq!(segment.byte_range, Some(ByteRange::new(0, Some(75232)))); 29 | 30 | let segment = &segments[1]; 31 | assert_eq!(segment.url, format!("{}/video.ts", server.uri()).parse()?); 32 | assert_eq!( 33 | segment.byte_range, 34 | Some(ByteRange::new(752321, Some(82112))) 35 | ); 36 | 37 | let segment = &segments[2]; 38 | assert_eq!(segment.url, format!("{}/video.ts", server.uri()).parse()?); 39 | assert_eq!( 40 | segment.byte_range, 41 | Some(ByteRange::new(834433, Some(69864))) 42 | ); 43 | 44 | Ok(()) 45 | } 46 | 47 | #[tokio::test] 48 | async fn mediaplaylist_byterange() -> anyhow::Result<()> { 49 | let data = include_str!("../fixtures/hls/m3u8-rs/mediaplaylist-byterange.m3u8"); 50 | let (playlist_uri, server) = setup_mock_server(data).await; 51 | 52 | let context = IoriContext::default(); 53 | let mut playlist = HlsPlaylistSource::new(playlist_uri.parse()?, None); 54 | 55 | let latest_media_sequences = playlist.load_streams(&context).await?; 56 | let (streams, is_end) = playlist 57 | .load_segments(&context, &latest_media_sequences) 58 | .await?; 59 | 60 | assert!(is_end); 61 | assert_eq!(streams.len(), 1); 62 | 63 | let segments = &streams[0]; 64 | assert_eq!(segments.len(), 8); 65 | 66 | let segment = &segments[0]; 67 | assert_eq!(segment.url, format!("{}/main.aac", server.uri()).parse()?); 68 | assert_eq!(segment.byte_range, Some(ByteRange::new(0, Some(86920)))); 69 | 70 | let segment = &segments[1]; 71 | assert_eq!(segment.url, format!("{}/main.aac", server.uri()).parse()?); 72 | assert_eq!( 73 | segment.byte_range, 74 | Some(ByteRange::new(86920, Some(136595))) 75 | ); 76 | 77 | let segment = &segments[2]; 78 | assert_eq!(segment.url, format!("{}/main.aac", server.uri()).parse()?); 79 | assert_eq!( 80 | segment.byte_range, 81 | Some(ByteRange::new(223515, Some(136567))) 82 | ); 83 | 84 | let segment = &segments[3]; 85 | assert_eq!(segment.url, format!("{}/main.aac", server.uri()).parse()?); 86 | assert_eq!( 87 | segment.byte_range, 88 | Some(ByteRange::new(360082, Some(136954))) 89 | ); 90 | 91 | let segment = &segments[4]; 92 | assert_eq!(segment.url, format!("{}/main.aac", server.uri()).parse()?); 93 | assert_eq!( 94 | segment.byte_range, 95 | Some(ByteRange::new(497036, Some(137116))) 96 | ); 97 | 98 | let segment = &segments[5]; 99 | assert_eq!(segment.url, format!("{}/main.aac", server.uri()).parse()?); 100 | assert_eq!( 101 | segment.byte_range, 102 | Some(ByteRange::new(634152, Some(136770))) 103 | ); 104 | 105 | let segment = &segments[6]; 106 | assert_eq!(segment.url, format!("{}/main.aac", server.uri()).parse()?); 107 | assert_eq!( 108 | segment.byte_range, 109 | Some(ByteRange::new(770922, Some(137219))) 110 | ); 111 | 112 | let segment = &segments[7]; 113 | assert_eq!(segment.url, format!("{}/main.aac", server.uri()).parse()?); 114 | assert_eq!( 115 | segment.byte_range, 116 | Some(ByteRange::new(908141, Some(137132))) 117 | ); 118 | 119 | Ok(()) 120 | } 121 | -------------------------------------------------------------------------------- /crates/iori/tests/source.rs: -------------------------------------------------------------------------------- 1 | use futures::{Stream, StreamExt, stream}; 2 | use iori::context::IoriContext; 3 | use iori::{ 4 | InitialSegment, IoriError, IoriResult, SegmentFormat, StreamType, StreamingSegment, 5 | StreamingSource, WriteSegment, 6 | }; 7 | use std::sync::Arc; 8 | use std::sync::atomic::{AtomicU8, Ordering}; 9 | use tokio::io::AsyncWriteExt; 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, 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::other( 27 | "Failed to write data", 28 | ))); 29 | } 30 | 31 | let data = format!("Segment {} from stream {}", self.sequence, self.stream_id); 32 | writer.write_all(data.as_bytes()).await?; 33 | Ok(()) 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 | InitialSegment::None 52 | } 53 | 54 | fn key(&self) -> Option> { 55 | None 56 | } 57 | 58 | fn stream_type(&self) -> StreamType { 59 | StreamType::Video 60 | } 61 | 62 | fn format(&self) -> SegmentFormat { 63 | SegmentFormat::Mpeg2TS 64 | } 65 | } 66 | 67 | #[derive(Clone)] 68 | pub struct TestSource { 69 | segments: Vec, 70 | } 71 | 72 | impl TestSource { 73 | pub fn new(segments: Vec) -> Self { 74 | Self { segments } 75 | } 76 | } 77 | 78 | impl StreamingSource for TestSource { 79 | type Segment = TestSegment; 80 | 81 | async fn segments_stream( 82 | &self, 83 | _: &IoriContext, 84 | ) -> IoriResult>>> { 85 | Ok(Box::pin(stream::once(async { Ok(self.segments.clone()) }))) 86 | } 87 | } 88 | 89 | impl WriteSegment for TestSegment { 90 | async fn write_segment(&self, _: &IoriContext, writer: &mut W) -> IoriResult<()> 91 | where 92 | W: tokio::io::AsyncWrite + Unpin + Send, 93 | { 94 | self.write_data(writer).await 95 | } 96 | } 97 | 98 | #[tokio::test] 99 | async 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 context = IoriContext::default(); 116 | let source = TestSource::new(segments.clone()); 117 | let mut stream = source 118 | .segments_stream(&context) 119 | .await 120 | .expect("Failed to get segments stream"); 121 | 122 | let mut received_segments: Vec = Vec::new(); 123 | while let Some(result) = stream.next().await { 124 | received_segments.extend(result.unwrap()); 125 | } 126 | 127 | assert_eq!(received_segments.len(), segments.len()); 128 | for (received, expected) in received_segments.iter().zip(segments.iter()) { 129 | assert_eq!(received.stream_id(), expected.stream_id()); 130 | assert_eq!(received.sequence(), expected.sequence()); 131 | assert_eq!(received.file_name(), expected.file_name()); 132 | } 133 | } 134 | 135 | #[tokio::test] 136 | async fn test_streaming_source_fetch_segment() { 137 | let segment = TestSegment { 138 | stream_id: 1, 139 | sequence: 0, 140 | file_name: "segment0.ts".to_string(), 141 | fail_count: Arc::new(AtomicU8::new(0)), 142 | }; 143 | 144 | let mut writer = Vec::new(); 145 | segment 146 | .write_segment(&Default::default(), &mut writer) 147 | .await 148 | .unwrap(); 149 | 150 | let data = String::from_utf8(writer).unwrap(); 151 | assert_eq!(data, "Segment 0 from stream 1"); 152 | } 153 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------