commit 504422871fc17cf2ca3112ed2e1ca79b87d61a3a Author: KhooLy <73142442+KhooLy@users.noreply.github.com> Date: Tue Jun 9 14:24:41 2026 +0300 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..98043b1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/target/ +CLAUDE.md +*.pdb +stremio-addon-api/ diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..e29d178 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1485 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "askama" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75363874b771be265f4ffe307ca705ef6f3baa19011c149da8674a87f1b75c4" +dependencies = [ + "askama_derive", + "itoa", + "percent-encoding", + "serde", + "serde_json", +] + +[[package]] +name = "askama_derive" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "129397200fe83088e8a68407a8e2b1f826cf0086b21ccdb866a722c8bcd3a94f" +dependencies = [ + "askama_parser", + "basic-toml", + "memchr", + "proc-macro2", + "quote", + "rustc-hash", + "serde", + "serde_derive", + "syn", +] + +[[package]] +name = "askama_parser" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6ab5630b3d5eaf232620167977f95eb51f3432fc76852328774afbd242d4358" +dependencies = [ + "memchr", + "serde", + "serde_derive", + "winnow 0.7.15", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "basic-toml" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" +dependencies = [ + "serde", +] + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "bitstream-io" +version = "4.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eff00be299a18769011411c9def0d827e8f2d7bf0c3dbf53633147a8867fd1f" +dependencies = [ + "no_std_io2", +] + +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "bitvec_helpers" +version = "4.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52e5938870db620c26a8f329f5735aa3450cedd30f4a4bb98475b1b076d8edae" +dependencies = [ + "bitstream-io", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "camino" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "cc" +version = "1.2.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.5.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" +dependencies = [ + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "dolby_vision" +version = "3.3.2" +source = "git+https://github.com/quietvoid/dovi_tool.git#ccf640939377ee19035bbf871040bf79d437fb6b" +dependencies = [ + "anyhow", + "bitvec", + "bitvec_helpers", + "crc", + "tinyvec", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fluxa_core" +version = "0.1.0" +dependencies = [ + "base64", + "chrono", + "dolby_vision", + "jni", + "regex", + "serde", + "serde_json", + "uniffi", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "fs-err" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a41f105fe1d5b6b34b2055e3dc59bb79b46b48b2040b9e6c7b4b5de097aa41" +dependencies = [ + "autocfg", +] + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "goblin" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b363a30c165f666402fe6a3024d3bec7ebc898f96a4a23bd1c99f8dbf3f4f47" +dependencies = [ + "log", + "plain", + "scroll", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "js-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "no_std_io2" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003" +dependencies = [ + "memchr", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scroll" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ab8598aa408498679922eff7fa985c25d58a90771bd6be794434c5277eab1a6" +dependencies = [ + "scroll_derive", +] + +[[package]] +name = "scroll_derive" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1783eabc414609e28a5ba76aee5ddd52199f7107a0b24c2e9746a1ecc34a683d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smawk" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "textwrap" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +dependencies = [ + "smawk", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.3", +] + +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "uniffi" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc5f2297ee5b893405bed1a6929faec4713a061df158ecf5198089f23910d470" +dependencies = [ + "anyhow", + "camino", + "cargo_metadata", + "clap", + "uniffi_bindgen", + "uniffi_core", + "uniffi_macros", + "uniffi_pipeline", +] + +[[package]] +name = "uniffi_bindgen" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bc0c60a9607e7ab77a2ad47ec5530178015014839db25af7512447d2238016c" +dependencies = [ + "anyhow", + "askama", + "camino", + "cargo_metadata", + "fs-err", + "glob", + "goblin", + "heck", + "indexmap", + "once_cell", + "serde", + "tempfile", + "textwrap", + "toml", + "uniffi_internal_macros", + "uniffi_meta", + "uniffi_pipeline", + "uniffi_udl", +] + +[[package]] +name = "uniffi_core" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77baf5d539fe2e1ad6805e942dbc5dbdeb2b83eb5f2b3a6535d422ca4b02a12f" +dependencies = [ + "anyhow", + "bytes", + "once_cell", + "static_assertions", +] + +[[package]] +name = "uniffi_internal_macros" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4b42137524f4be6400fcaca9d02c1d4ecb6ad917e4013c0b93235526d8396e5" +dependencies = [ + "anyhow", + "indexmap", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "uniffi_macros" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9273ec45330d8fe9a3701b7b983cea7a4e218503359831967cb95d26b873561" +dependencies = [ + "camino", + "fs-err", + "once_cell", + "proc-macro2", + "quote", + "serde", + "syn", + "toml", + "uniffi_meta", +] + +[[package]] +name = "uniffi_meta" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "431d2f443e7828a6c29d188de98b6771a6491ee98bba2d4372643bf93f988a18" +dependencies = [ + "anyhow", + "siphasher", + "uniffi_internal_macros", + "uniffi_pipeline", +] + +[[package]] +name = "uniffi_pipeline" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "761ef74f6175e15603d0424cc5f98854c5baccfe7bf4ccb08e5816f9ab8af689" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "tempfile", + "uniffi_internal_macros", +] + +[[package]] +name = "uniffi_udl" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68773ec0e1c067b6505a73bbf6a5782f31a7f9209333a0df97b87565c46bf370" +dependencies = [ + "anyhow", + "textwrap", + "uniffi_meta", + "weedle2", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "weedle2" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998d2c24ec099a87daf9467808859f9d82b61f1d9c9701251aea037f514eae0e" +dependencies = [ + "nom", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..1511aaa --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "fluxa_core" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib", "rlib"] + +[[bin]] +name = "uniffi-bindgen" +path = "uniffi-bindgen.rs" +required-features = ["uniffi-cli"] + +[features] +default = ["native"] +native = [ + "dep:jni", +] +uniffi-cli = ["uniffi/cli"] +wasm = [] + +[dependencies] +base64 = "0.22" +chrono = { version = "0.4.45", features = ["serde"] } +dolby_vision = { git = "https://github.com/quietvoid/dovi_tool.git", package = "dolby_vision", default-features = false } +jni = { version = "0.21", optional = true } +regex = "1" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +uniffi = "0.31.1" + +[profile.release] +opt-level = 3 +lto = true +codegen-units = 1 +strip = "debuginfo" +panic = "abort" diff --git a/README.md b/README.md new file mode 100644 index 0000000..fcf6566 --- /dev/null +++ b/README.md @@ -0,0 +1,145 @@ +
+ +# fluxa-core + + [![Contributors][contributors-shield]][contributors-url] + [![Forks][forks-shield]][forks-url] + [![Stars][stars-shield]][stars-url] + [![Issues][issues-shield]][issues-url] + [![License][license-shield]][license-url] + +

+ The platform-agnostic Rust brain behind Fluxa.
+ State management · Stream policy · Addon protocol · Effect-driven I/O +

+ +
+ +--- + +## What is fluxa-core? + +`fluxa-core` is a headless Rust library that contains all of Fluxa's domain logic. It handles content discovery, stream selection, playback state, user profiles, library management, calendar tracking, and external integrations (Trakt, Simkl) — with no platform-specific code inside Rust. + +**Rust never calls the network.** Instead, the engine emits typed effects that the host platform (Kotlin on Android, or a native Rust runtime on desktop) executes and reports back. This keeps the same crate portable across Android (JNI), desktop (UniFFI), and future targets (WASM). + +--- + +## Architecture + +### The Effect Loop + +``` +Host (Kotlin / native) → dispatch(action_json) + ← { state, effects: [{ id, type, payload }] } +Host → executes each effect (HTTP / storage / player / ...) + → completeEffect({ effectId, result }) + ← { state, effects: [...] } +``` + +The host owns all I/O. The Rust engine owns all decisions. + +### Binding Layers + +``` +┌─────────────────────────────────────────────┐ +│ fluxa-core (Rust) │ +│ │ +│ headless_engine ← domain modules │ +│ ↓ │ +│ ┌─────────────┬──────────────┐ │ +│ │ JNI (jni) │ UniFFI │ │ +│ │ Android │ Desktop/WASM │ │ +│ └─────────────┴──────────────┘ │ +└─────────────────────────────────────────────┘ +``` + +JNI bindings are compiled under the `native` feature (default). UniFFI bindings are always compiled and are the long-term cross-platform target. + +--- + +## What's Inside + +| Module | Responsibility | +|--------|----------------| +| `headless_engine` | Central state machine — dispatches actions, owns pending effects | +| `addon_protocol` | Manifest URL normalisation, resource URL construction, manifest merging | +| `stream_policy` | Stream selection, magnet building, audio/subtitle preference matching | +| `player_policy` | Backend selection (ExoPlayer / MPV / external), track state | +| `player_flow` | Playback state machine (load → select → play → scrobble) | +| `content_identity` | ID parsing and normalisation (IMDB, TMDB, Kitsu, episode locators) | +| `home_ranking` | Billboard and shelf ordering, continue-watching deduplication | +| `library_state` | Continue-watching badge computation, next-episode resolution | +| `profile_contract` | Profile activation, auth token merging, settings migration | +| `profile_prefs` | Typed read of user preferences from raw profile JSON | +| `search_plan` | Search query planning, result grouping, discover sort | +| `calendar_plan` | Calendar item filtering, widget rows, release notifications | +| `external_sync` | Trakt and Simkl API response parsing and history mapping | +| `addon_store` | Addon search policy, CloudStream repo URL normalisation | +| `addon_resource` | Addon HTTP response classification (success / empty / error) | +| `intro_segments` | introdb.app and AniSkip segment parsing, deduplication | +| `dolby_vision_rpu` | Dolby Vision RPU metadata extraction for HDR stream selection | +| `platform_plan` | Season/episode navigation planning for the detail screen | +| `tmdb_plan` | TMDB ID resolution hints, trailer mapping | +| `discovery_plan` | Discover catalog request planning | +| `watchlist_plan` | Watchlist toggle, offline grouping, progress merging | + +--- + +## Building + +### Prerequisites + +- Rust toolchain (stable) — install via [rustup](https://rustup.rs/) +- For Android targets, the NDK and cross-compilation targets: + +```bash +rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android +``` + +### Library + +```bash +cargo build --release +``` + +### Tests + +```bash +cargo test +``` + +### Android (via Fluxa) + +The Android app builds this crate as part of its Gradle build. See the [Fluxa](https://github.com/KhooLy/Fluxa) repo for the full build setup. + +### UniFFI bindings (desktop) + +```bash +cargo build --release --features uniffi-cli +cargo run --features uniffi-cli --bin uniffi-bindgen generate \ + --library target/release/libfluxa_core.so \ + --language kotlin \ + --out-dir bindings/ +``` + +--- + +## Used By + +- [Fluxa](https://github.com/KhooLy/Fluxa) — Android media hub (Kotlin + Jetpack Compose) +- [FluxaDesktop](https://github.com/KhooLy/FluxaDesktop) — Desktop media hub (Rust + Tauri) + +--- + + +[contributors-shield]: https://img.shields.io/github/contributors/KhooLy/fluxa-core.svg?style=for-the-badge +[contributors-url]: https://github.com/KhooLy/fluxa-core/graphs/contributors +[forks-shield]: https://img.shields.io/github/forks/KhooLy/fluxa-core.svg?style=for-the-badge +[forks-url]: https://github.com/KhooLy/fluxa-core/network/members +[stars-shield]: https://img.shields.io/github/stars/KhooLy/fluxa-core.svg?style=for-the-badge +[stars-url]: https://github.com/KhooLy/fluxa-core/stargazers +[issues-shield]: https://img.shields.io/github/issues/KhooLy/fluxa-core.svg?style=for-the-badge +[issues-url]: https://github.com/KhooLy/fluxa-core/issues +[license-shield]: https://img.shields.io/github/license/KhooLy/fluxa-core.svg?style=for-the-badge +[license-url]: https://github.com/KhooLy/fluxa-core/blob/master/LICENSE diff --git a/src/addon_protocol.rs b/src/addon_protocol.rs new file mode 100644 index 0000000..5f49b56 --- /dev/null +++ b/src/addon_protocol.rs @@ -0,0 +1,972 @@ +use serde_json::{json, Map, Value}; +use std::collections::HashSet; + +pub(crate) fn is_http_url(value: &str) -> bool { + let lower = value.to_ascii_lowercase(); + lower.starts_with("http://") || lower.starts_with("https://") +} + +pub(crate) fn strip_http_scheme(value: &str) -> &str { + value + .strip_prefix("http://") + .or_else(|| value.strip_prefix("https://")) + .or_else(|| value.strip_prefix("HTTP://")) + .or_else(|| value.strip_prefix("HTTPS://")) + .unwrap_or(value) +} + +pub(crate) fn is_ipv4_like_host(value: &str) -> bool { + let host = value + .split('/') + .next() + .unwrap_or(value) + .split(':') + .next() + .unwrap_or(value); + let parts: Vec<&str> = host.split('.').collect(); + parts.len() == 4 && parts.iter().all(|part| part.parse::().is_ok()) +} + +pub(crate) fn is_local_url(value: &str) -> bool { + let lower = strip_http_scheme(value).to_ascii_lowercase(); + if lower.starts_with("localhost") + || lower.starts_with("127.") + || lower.starts_with("10.") + || lower.starts_with("192.168.") + { + return true; + } + // 172.16.0.0/12 private range + if let Some(rest) = lower.strip_prefix("172.") { + if let Some(second_octet) = rest.split('.').next().and_then(|s| s.parse::().ok()) { + if (16..=31).contains(&second_octet) { + return true; + } + } + } + false +} + +pub(crate) fn normalize_manifest_url(raw: &str) -> String { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return String::new(); + } + let lower = trimmed.to_ascii_lowercase(); + let with_scheme = if lower.starts_with("stremio://") { + format!("https://{}", &trimmed[10..]) + } else if lower.starts_with("http://") { + if is_local_url(trimmed) { + trimmed.to_string() + } else { + format!("https://{}", &trimmed[7..]) + } + } else if lower.starts_with("https://") { + trimmed.to_string() + } else if lower.starts_with("127.0.0.1") + || lower.starts_with("10.0.2.2") + || lower.starts_with("localhost") + || is_ipv4_like_host(trimmed) + { + format!("http://{trimmed}") + } else { + format!("https://{trimmed}") + }; + + if with_scheme.to_ascii_lowercase().ends_with("manifest.json") { + with_scheme + } else if with_scheme.ends_with('/') { + format!("{with_scheme}manifest.json") + } else { + format!("{with_scheme}/manifest.json") + } +} + +pub(crate) fn identity(raw: &str) -> String { + normalize_manifest_url(raw) + .trim_end_matches('/') + .trim_start_matches("https://") + .trim_start_matches("http://") + .to_string() +} + +pub(crate) fn manifest_candidates(raw: &str) -> Vec { + let normalized = normalize_manifest_url(raw); + let mut values = vec![normalized.clone()]; + if is_local_url(&normalized) && normalized.to_ascii_lowercase().starts_with("https://") { + let fallback = format!("http://{}", &normalized[8..]); + if !values.contains(&fallback) { + values.push(fallback); + } + } + values +} + +pub(crate) fn manifest_fetch_plan_json(raw: &str) -> Option { + let normalized_transport_url = normalize_manifest_url(raw); + if normalized_transport_url.is_empty() { + return None; + } + serde_json::to_string(&json!({ + "normalizedTransportUrl": normalized_transport_url, + "cacheKey": format!("manifest_v10_{}", normalized_transport_url), + "candidateUrls": manifest_candidates(&normalized_transport_url) + })) + .ok() +} + +pub(crate) fn base_url(raw: &str) -> String { + let normalized = normalize_manifest_url(raw); + let without_manifest = match normalized.to_ascii_lowercase().rfind("manifest.json") { + Some(index) => normalized[..index].to_string(), + None => normalized, + }; + let mut base = if without_manifest.ends_with('/') { + without_manifest + } else { + format!("{without_manifest}/") + }; + let lower = base.to_ascii_lowercase(); + if lower.contains("localhost") || lower.contains("127.0.0.1") { + if lower.starts_with("https://") { + base = format!("http://{}", &base[8..]); + } + } + base +} + +pub(crate) fn prefer_https_asset_url(raw: &str) -> Option { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return None; + } + let lower = trimmed.to_ascii_lowercase(); + if lower.starts_with("http://") { + if is_local_url(trimmed) { + Some(trimmed.to_string()) + } else { + Some(format!("https://{}", &trimmed[7..])) + } + } else if trimmed.starts_with("//") { + Some(format!("https:{trimmed}")) + } else { + Some(trimmed.to_string()) + } +} + +pub(crate) fn encode_path_segment(value: &str) -> String { + let mut encoded = String::with_capacity(value.len()); + for byte in value.bytes() { + let keep = byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'*'); + if keep { + encoded.push(byte as char); + } else { + encoded.push_str(&format!("%{byte:02X}")); + } + } + encoded +} + +pub(crate) fn build_resource_url( + raw: &str, + resource: &str, + content_type: &str, + id: &str, + extra_json: Option<&str>, +) -> String { + let extra_path = extra_json + .and_then(|value| serde_json::from_str::>(value).ok()) + .map(|map| { + map.into_iter() + .filter_map(|(key, value)| { + let text = value + .as_str() + .map(str::to_owned) + .unwrap_or_else(|| value.to_string()); + if text.trim().is_empty() { + None + } else { + Some(format!( + "{}={}", + encode_path_segment(&key), + encode_path_segment(&text) + )) + } + }) + .collect::>() + .join("&") + }) + .filter(|value| !value.is_empty()) + .map(|value| format!("/{value}")) + .unwrap_or_default(); + format!( + "{}{}/{}/{}{}.json", + base_url(raw), + resource, + encode_path_segment(content_type), + encode_path_segment(id), + extra_path + ) +} + +pub(crate) fn string_array(json: &Value, key: &str) -> Vec { + json.get(key) + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .filter_map(|item| { + item.as_str() + .filter(|text| !text.is_empty()) + .map(|text| Value::String(text.to_string())) + }) + .collect() + }) + .unwrap_or_default() +} + +pub(crate) fn first_text( + json: &Value, + behavior_hints: Option<&Value>, + keys: &[&str], +) -> Option { + keys.iter().find_map(|key| { + json.get(*key) + .and_then(Value::as_str) + .filter(|text| !text.is_empty()) + .or_else(|| { + behavior_hints + .and_then(|hints| hints.get(*key)) + .and_then(Value::as_str) + .filter(|text| !text.is_empty()) + }) + .map(str::to_string) + }) +} + +pub(crate) fn resolve_asset_url(asset: Option, manifest_url: &str) -> Option { + let secure = prefer_https_asset_url(asset?.as_str())?; + if is_http_url(&secure) { + return Some(secure); + } + if secure.starts_with('/') { + let base = base_url(manifest_url); + let scheme_end = base.find("://").map(|index| index + 3)?; + let host_end = base[scheme_end..] + .find('/') + .map(|index| scheme_end + index) + .unwrap_or(base.len()); + return prefer_https_asset_url(&format!("{}{}", &base[..host_end], secure)); + } + prefer_https_asset_url(&format!("{}{}", base_url(manifest_url), secure)) +} + +fn text_value<'a>(value: &'a Value, key: &str) -> Option<&'a str> { + value + .get(key) + .and_then(Value::as_str) + .filter(|text| !text.is_empty()) +} + +fn non_empty_array(value: Option<&Value>) -> Option { + value + .and_then(Value::as_array) + .filter(|items| !items.is_empty()) + .cloned() + .map(Value::Array) +} + +fn set_or_null(map: &mut Map, key: &str, value: Option) { + map.insert( + key.to_string(), + value.map(Value::String).unwrap_or(Value::Null), + ); +} + +fn value_has_content(value: &Value) -> bool { + match value { + Value::Null => false, + Value::String(text) => !text.trim().is_empty(), + Value::Array(items) => !items.is_empty(), + Value::Object(map) => !map.is_empty(), + Value::Bool(_) | Value::Number(_) => true, + } +} + +pub(crate) fn resolve_manifest_assets_json(descriptor_json: &str) -> Option { + let mut descriptor: Value = serde_json::from_str(descriptor_json).ok()?; + let transport_url = descriptor + .get("transportUrl") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + let normalized_transport_url = normalize_manifest_url(&transport_url); + descriptor["transportUrl"] = Value::String(normalized_transport_url.clone()); + + let manifest = descriptor.get_mut("manifest")?.as_object_mut()?; + let logo = text_value(&Value::Object(manifest.clone()), "logo").map(str::to_string); + let background = text_value(&Value::Object(manifest.clone()), "background").map(str::to_string); + let resolved_background = resolve_asset_url(background.clone(), &normalized_transport_url); + let resolved_logo = + resolve_asset_url(logo, &normalized_transport_url).or_else(|| resolved_background.clone()); + let description = manifest + .get("description") + .and_then(Value::as_str) + .filter(|text| !text.is_empty()) + .map(str::to_string); + + set_or_null(manifest, "description", description); + set_or_null(manifest, "logo", resolved_logo); + set_or_null(manifest, "background", resolved_background); + serde_json::to_string(&descriptor).ok() +} + +pub(crate) fn merge_live_manifest_json( + descriptor_json: &str, + live_json: Option<&str>, + unknown_name: &str, +) -> Option { + let Some(live_json) = live_json.filter(|value| !value.trim().is_empty()) else { + return resolve_manifest_assets_json(descriptor_json); + }; + let mut descriptor: Value = serde_json::from_str(descriptor_json).ok()?; + let live: Value = serde_json::from_str(live_json).ok()?; + let transport_url = descriptor + .get("transportUrl") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + let normalized_transport_url = normalize_manifest_url(&transport_url); + descriptor["transportUrl"] = Value::String(normalized_transport_url.clone()); + + let current_manifest_snapshot = descriptor.get("manifest")?.clone(); + let current_manifest = current_manifest_snapshot.as_object()?; + let live_manifest = live.get("manifest")?.as_object()?; + let manifest = descriptor.get_mut("manifest")?.as_object_mut()?; + + if let Some(id) = text_value(&Value::Object(live_manifest.clone()), "id") { + manifest.insert("id".to_string(), Value::String(id.to_string())); + } + if let Some(name) = text_value(&Value::Object(live_manifest.clone()), "name") + .filter(|name| *name != unknown_name) + { + manifest.insert("name".to_string(), Value::String(name.to_string())); + } + if let Some(description) = text_value(&Value::Object(live_manifest.clone()), "description") { + manifest.insert( + "description".to_string(), + Value::String(description.to_string()), + ); + } + for (key, value) in live_manifest { + if matches!( + key.as_str(), + "id" | "name" | "description" | "logo" | "background" + ) { + continue; + } + if matches!( + key.as_str(), + "resources" | "types" | "catalogs" | "idPrefixes" + ) { + if let Some(value) = non_empty_array(Some(value)) { + manifest.insert(key.to_string(), value); + } + continue; + } + if value_has_content(value) { + manifest.insert(key.to_string(), value.clone()); + } + } + + let current_logo = current_manifest + .get("logo") + .and_then(Value::as_str) + .map(str::to_string); + let current_background = current_manifest + .get("background") + .and_then(Value::as_str) + .map(str::to_string); + let live_logo = live_manifest + .get("logo") + .and_then(Value::as_str) + .filter(|text| !text.is_empty()) + .map(str::to_string); + let live_background = live_manifest + .get("background") + .and_then(Value::as_str) + .filter(|text| !text.is_empty()) + .map(str::to_string); + + let resolved_current_logo = resolve_asset_url(current_logo, &normalized_transport_url); + let resolved_current_background = + resolve_asset_url(current_background, &normalized_transport_url); + let logo = live_logo + .or(resolved_current_logo) + .or_else(|| live_background.clone()) + .or_else(|| resolved_current_background.clone()); + let background = live_background.or(resolved_current_background); + set_or_null(manifest, "logo", logo); + set_or_null(manifest, "background", background); + + serde_json::to_string(&descriptor).ok() +} + +pub(crate) fn parse_catalogs(json: &Value) -> Vec { + json.get("catalogs") + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .filter_map(|item| { + let object = item.as_object()?; + let mut map = object.clone(); + let extra = object + .get("extra") + .and_then(Value::as_array) + .map(|extras| { + extras + .iter() + .filter_map(|extra| { + let extra_object = extra.as_object()?; + let mut map = Map::new(); + if let Some(name) = extra_object + .get("name") + .and_then(Value::as_str) + .filter(|text| !text.is_empty()) + { + map.insert( + "name".to_string(), + Value::String(name.to_string()), + ); + } + let options = string_array(extra, "options"); + if !options.is_empty() { + map.insert("options".to_string(), Value::Array(options)); + } + if let Some(is_required) = + extra_object.get("isRequired").and_then(Value::as_bool) + { + map.insert( + "isRequired".to_string(), + Value::Bool(is_required), + ); + } + if let Some(options_limit) = + extra_object.get("optionsLimit").and_then(Value::as_i64) + { + map.insert( + "optionsLimit".to_string(), + json!(options_limit as i32), + ); + } + Some(Value::Object(map)) + }) + .collect::>() + }) + .unwrap_or_default(); + if !extra.is_empty() { + map.insert("extra".to_string(), Value::Array(extra)); + } + Some(Value::Object(map)) + }) + .collect() + }) + .unwrap_or_default() +} + +pub(crate) fn parse_manifest( + body: &str, + transport_url: &str, + unknown_name: &str, +) -> Option { + let json: Value = serde_json::from_str(body).ok()?; + let behavior_hints = json.get("behaviorHints"); + let logo = first_text( + &json, + behavior_hints, + &["logo", "icon", "iconUrl", "poster", "posterUrl"], + ); + let background = first_text( + &json, + behavior_hints, + &["background", "backgroundUrl", "backdrop", "backdropUrl"], + ); + let description = first_text( + &json, + behavior_hints, + &[ + "description", + "shortDescription", + "longDescription", + "summary", + ], + ); + let resources = json + .get("resources") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + let id_prefixes = string_array(&json, "idPrefixes"); + let mut manifest = json.as_object().cloned().unwrap_or_default(); + manifest.insert( + "id".to_string(), + Value::String( + json.get("id") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(), + ), + ); + manifest.insert( + "name".to_string(), + Value::String( + json.get("name") + .and_then(Value::as_str) + .filter(|text| !text.is_empty()) + .unwrap_or(unknown_name) + .to_string(), + ), + ); + manifest.insert( + "description".to_string(), + description.map(Value::String).unwrap_or(Value::Null), + ); + manifest.insert( + "version".to_string(), + first_text(&json, behavior_hints, &["version"]) + .map(Value::String) + .unwrap_or(Value::Null), + ); + manifest.insert("resources".to_string(), Value::Array(resources)); + manifest.insert( + "types".to_string(), + Value::Array(string_array(&json, "types")), + ); + manifest.insert("catalogs".to_string(), Value::Array(parse_catalogs(&json))); + manifest.insert( + "idPrefixes".to_string(), + if id_prefixes.is_empty() { + Value::Null + } else { + Value::Array(id_prefixes) + }, + ); + manifest.insert( + "logo".to_string(), + resolve_asset_url(logo, transport_url) + .map(Value::String) + .unwrap_or(Value::Null), + ); + manifest.insert( + "background".to_string(), + resolve_asset_url(background, transport_url) + .map(Value::String) + .unwrap_or(Value::Null), + ); + manifest.insert( + "configurable".to_string(), + behavior_hints + .and_then(|hints| hints.get("configurable")) + .and_then(Value::as_bool) + .map(Value::Bool) + .unwrap_or(Value::Null), + ); + + let descriptor = json!({ + "manifest": Value::Object(manifest), + "transportUrl": transport_url + }); + serde_json::to_string(&descriptor).ok() +} + +pub(crate) fn canonical_resource_name(value: &str) -> String { + match value.to_ascii_lowercase().trim_end_matches('s') { + "metadata" => "meta".to_string(), + "subtitle" => "subtitle".to_string(), + other => other.to_string(), + } +} + +pub(crate) fn to_string_vec(value: Option<&Value>) -> Vec { + match value { + Some(Value::Array(items)) => items + .iter() + .filter_map(|item| item.as_str().map(str::to_string)) + .filter(|item| !item.is_empty()) + .collect(), + Some(Value::String(text)) if !text.is_empty() => vec![text.to_string()], + _ => Vec::new(), + } +} + +pub(crate) fn supports_resource( + manifest_json: &str, + resource_name: &str, + content_type: Option<&str>, + id: Option<&str>, +) -> bool { + let Ok(manifest) = serde_json::from_str::(manifest_json) else { + return false; + }; + let expected = canonical_resource_name(resource_name); + let manifest_types = to_string_vec(manifest.get("types")); + let manifest_prefixes = to_string_vec(manifest.get("idPrefixes")); + manifest + .get("resources") + .and_then(Value::as_array) + .map(|resources| { + resources.iter().any(|resource| { + let (name, types, prefixes) = match resource { + Value::String(name) => ( + name.as_str(), + manifest_types.clone(), + manifest_prefixes.clone(), + ), + Value::Object(map) => { + let Some(name) = map.get("name").and_then(Value::as_str) else { + return false; + }; + let types = to_string_vec(map.get("types")) + .into_iter() + .chain(to_string_vec(map.get("type"))) + .collect::>(); + let prefixes = to_string_vec(map.get("idPrefixes")) + .into_iter() + .chain(to_string_vec(map.get("idPrefix"))) + .collect::>(); + ( + name, + if types.is_empty() { + manifest_types.clone() + } else { + types + }, + if prefixes.is_empty() { + manifest_prefixes.clone() + } else { + prefixes + }, + ) + } + _ => return false, + }; + if canonical_resource_name(name) != expected { + return false; + } + if let Some(content_type) = content_type { + if !types.is_empty() + && !types + .iter() + .any(|item| item.eq_ignore_ascii_case(content_type)) + { + return false; + } + } + if let Some(id) = id { + if canonical_resource_name(name) != "catalog" + && !prefixes.is_empty() + && !prefixes.iter().any(|prefix| id.starts_with(prefix)) + { + return false; + } + } + true + }) + }) + .unwrap_or(false) +} + +pub(crate) fn catalog_supports_extra(catalog_json: &str, extra_name: &str) -> bool { + let Ok(catalog) = serde_json::from_str::(catalog_json) else { + return false; + }; + catalog + .get("extraSupported") + .and_then(Value::as_array) + .is_some_and(|items| { + items + .iter() + .filter_map(Value::as_str) + .any(|item| item.eq_ignore_ascii_case(extra_name)) + }) + || catalog + .get("extra") + .and_then(Value::as_array) + .is_some_and(|items| { + items.iter().any(|item| { + item.get("name") + .and_then(Value::as_str) + .is_some_and(|name| name.eq_ignore_ascii_case(extra_name)) + }) + }) +} + +pub(crate) fn catalog_requires_extra(catalog_json: &str, extra_name: &str) -> bool { + let Ok(catalog) = serde_json::from_str::(catalog_json) else { + return false; + }; + catalog + .get("extra") + .and_then(Value::as_array) + .is_some_and(|items| { + items.iter().any(|item| { + item.get("name") + .and_then(Value::as_str) + .is_some_and(|name| name.eq_ignore_ascii_case(extra_name)) + && item.get("isRequired").and_then(Value::as_bool) == Some(true) + }) + }) +} + +pub(crate) fn catalog_has_required_extra_except( + catalog_json: &str, + allowed_names_json: &str, +) -> bool { + let Ok(catalog) = serde_json::from_str::(catalog_json) else { + return false; + }; + let allowed_names = serde_json::from_str::>(allowed_names_json) + .unwrap_or_default() + .into_iter() + .collect::>(); + catalog + .get("extra") + .and_then(Value::as_array) + .is_some_and(|items| { + items.iter().any(|item| { + item.get("isRequired").and_then(Value::as_bool) == Some(true) + && item + .get("name") + .and_then(Value::as_str) + .map(|name| !allowed_names.contains(&name.to_ascii_lowercase())) + .unwrap_or(true) + }) + }) +} + +#[cfg(test)] +mod tests { + use super::{merge_live_manifest_json, parse_manifest, resolve_manifest_assets_json}; + use serde_json::Value; + + #[test] + fn resolve_manifest_assets_normalizes_transport_and_relative_assets() { + let descriptor = r#"{ + "transportUrl":"addon.example/root/manifest.json", + "manifest":{ + "id":"addon", + "name":"Addon", + "description":"", + "resources":[], + "types":[], + "catalogs":[], + "logo":"logo.png", + "background":"/bg.jpg" + } + }"#; + let resolved = resolve_manifest_assets_json(descriptor) + .and_then(|json| serde_json::from_str::(&json).ok()) + .expect("resolved descriptor"); + + assert_eq!( + resolved.get("transportUrl").and_then(Value::as_str), + Some("https://addon.example/root/manifest.json") + ); + assert_eq!( + resolved + .get("manifest") + .and_then(|manifest| manifest.get("description")), + Some(&Value::Null) + ); + assert_eq!( + resolved + .get("manifest") + .and_then(|manifest| manifest.get("logo")) + .and_then(Value::as_str), + Some("https://addon.example/root/logo.png") + ); + assert_eq!( + resolved + .get("manifest") + .and_then(|manifest| manifest.get("background")) + .and_then(Value::as_str), + Some("https://addon.example/bg.jpg") + ); + } + + #[test] + fn manifest_fetch_plan_owns_cache_key_and_candidates() { + let plan = super::manifest_fetch_plan_json("127.0.0.1:7000") + .and_then(|json| serde_json::from_str::(&json).ok()) + .expect("manifest fetch plan"); + + assert_eq!( + plan.get("normalizedTransportUrl").and_then(Value::as_str), + Some("http://127.0.0.1:7000/manifest.json") + ); + assert_eq!( + plan.get("cacheKey").and_then(Value::as_str), + Some("manifest_v10_http://127.0.0.1:7000/manifest.json") + ); + assert_eq!( + plan.get("candidateUrls") + .and_then(Value::as_array) + .and_then(|items| items.first()) + .and_then(Value::as_str), + Some("http://127.0.0.1:7000/manifest.json") + ); + } + + #[test] + fn merge_live_manifest_keeps_current_fields_when_live_is_empty() { + let current = r#"{ + "transportUrl":"https://addon.example/manifest.json", + "manifest":{ + "id":"old", + "name":"Old", + "description":"Current", + "version":"1.0", + "resources":["stream"], + "types":["movie"], + "catalogs":[{"type":"movie","id":"old"}], + "logo":"logo.png", + "background":"bg.jpg", + "configurable":false + } + }"#; + let live = r#"{ + "transportUrl":"https://addon.example/manifest.json", + "manifest":{ + "id":"new", + "name":"Unknown", + "description":"", + "version":"2.0", + "resources":[], + "types":[], + "catalogs":[], + "logo":null, + "background":"live-bg.jpg", + "configurable":true + } + }"#; + let merged = merge_live_manifest_json(current, Some(live), "Unknown") + .and_then(|json| serde_json::from_str::(&json).ok()) + .expect("merged descriptor"); + let manifest = merged.get("manifest").expect("manifest"); + + assert_eq!(manifest.get("id").and_then(Value::as_str), Some("new")); + assert_eq!(manifest.get("name").and_then(Value::as_str), Some("Old")); + assert_eq!( + manifest.get("description").and_then(Value::as_str), + Some("Current") + ); + assert_eq!(manifest.get("version").and_then(Value::as_str), Some("2.0")); + assert_eq!( + manifest + .get("resources") + .and_then(Value::as_array) + .map(Vec::len), + Some(1) + ); + assert_eq!( + manifest.get("logo").and_then(Value::as_str), + Some("https://addon.example/logo.png") + ); + assert_eq!( + manifest.get("background").and_then(Value::as_str), + Some("live-bg.jpg") + ); + assert_eq!( + manifest.get("configurable").and_then(Value::as_bool), + Some(true) + ); + } + + #[test] + fn parse_manifest_preserves_stremio_manifest_fields() { + let parsed = parse_manifest( + r#"{ + "id":"org.example.full", + "version":"1.2.3", + "name":"Full", + "description":"All fields", + "resources":["catalog",{"name":"stream","types":["movie"],"idPrefixes":["tt"]}], + "types":["movie","series"], + "idPrefixes":["tt"], + "catalogs":[{"type":"movie","id":"top","name":"Top","extra":[{"name":"genre","isRequired":false,"options":["Drama"],"optionsLimit":2}],"extraSupported":["search"],"extraRequired":["genre"]}], + "addonCatalogs":[{"type":"addon","id":"community","name":"Community"}], + "config":[{"key":"token","type":"password","default":"x","title":"Token","required":true}], + "background":"/bg.jpg", + "logo":"logo.png", + "contactEmail":"ops@example.com", + "behaviorHints":{"adult":true,"p2p":true,"configurable":true,"configurationRequired":true} + }"#, + "https://addon.example/root/manifest.json", + "Unknown", + ) + .and_then(|json| serde_json::from_str::(&json).ok()) + .expect("parsed manifest"); + let manifest = parsed.get("manifest").expect("manifest"); + + assert_eq!( + manifest["addonCatalogs"][0]["id"].as_str(), + Some("community") + ); + assert_eq!(manifest["config"][0]["key"].as_str(), Some("token")); + assert_eq!(manifest["contactEmail"].as_str(), Some("ops@example.com")); + assert_eq!( + manifest["behaviorHints"]["configurationRequired"].as_bool(), + Some(true) + ); + assert_eq!( + manifest["catalogs"][0]["extraRequired"][0].as_str(), + Some("genre") + ); + assert_eq!( + manifest["logo"].as_str(), + Some("https://addon.example/root/logo.png") + ); + assert_eq!( + manifest["background"].as_str(), + Some("https://addon.example/bg.jpg") + ); + } + + #[test] + fn merge_live_manifest_preserves_new_live_manifest_fields() { + let current = r#"{ + "transportUrl":"https://addon.example/manifest.json", + "manifest":{"id":"old","name":"Old","description":"Current","resources":["stream"],"types":["movie"],"catalogs":[]} + }"#; + let live = r#"{ + "manifest":{ + "id":"old", + "name":"Live", + "description":"Live description", + "resources":["stream"], + "types":["movie"], + "catalogs":[], + "addonCatalogs":[{"type":"addon","id":"community","name":"Community"}], + "config":[{"key":"token","type":"password"}], + "contactEmail":"ops@example.com", + "behaviorHints":{"configurable":true,"configurationRequired":true} + } + }"#; + let merged = merge_live_manifest_json(current, Some(live), "Unknown") + .and_then(|json| serde_json::from_str::(&json).ok()) + .expect("merged manifest"); + let manifest = merged.get("manifest").expect("manifest"); + + assert_eq!( + manifest["addonCatalogs"][0]["id"].as_str(), + Some("community") + ); + assert_eq!(manifest["config"][0]["key"].as_str(), Some("token")); + assert_eq!(manifest["contactEmail"].as_str(), Some("ops@example.com")); + assert_eq!( + manifest["behaviorHints"]["configurationRequired"].as_bool(), + Some(true) + ); + } +} diff --git a/src/addon_resource.rs b/src/addon_resource.rs new file mode 100644 index 0000000..71f184f --- /dev/null +++ b/src/addon_resource.rs @@ -0,0 +1,286 @@ +use crate::addon_protocol; +use serde_json::{json, Value}; + +fn resource_payload<'a>(resource: &str, root: &'a Value) -> Option { + match resource { + "stream" | "streams" => root.get("streams").cloned(), + "catalog" | "metas" => root.get("metas").cloned(), + "meta" => root.get("meta").cloned(), + "subtitles" | "subtitle" => root.get("subtitles").cloned(), + other => root.get(other).cloned(), + } +} + +fn payload_is_empty(payload: &Value) -> bool { + match payload { + Value::Null => true, + Value::Array(values) => values.is_empty(), + Value::Object(values) => values.is_empty(), + _ => false, + } +} + +fn cache_value(root: &Value, key: &str) -> Value { + root.get(key) + .and_then(Value::as_i64) + .map(|value| json!(value)) + .unwrap_or(Value::Null) +} + +pub(crate) fn parse_addon_resource_result_json( + resource: &str, + url: &str, + status_code: i32, + body: Option<&str>, +) -> String { + if !(200..=299).contains(&status_code) { + return json!({ + "kind": "network_error", + "url": url, + "statusCode": status_code + }) + .to_string(); + } + + let Some(body) = body.map(str::trim).filter(|value| !value.is_empty()) else { + return json!({ + "kind": "empty", + "url": url, + "statusCode": status_code + }) + .to_string(); + }; + + let root: Value = match serde_json::from_str(body) { + Ok(root) => root, + Err(error) => { + return json!({ + "kind": "parse_error", + "url": url, + "statusCode": status_code, + "error": error.to_string() + }) + .to_string() + } + }; + + let Some(payload) = resource_payload(resource, &root) else { + return json!({ + "kind": "empty", + "url": url, + "statusCode": status_code + }) + .to_string(); + }; + + if payload_is_empty(&payload) { + return json!({ + "kind": "empty", + "url": url, + "statusCode": status_code + }) + .to_string(); + } + + json!({ + "kind": "success", + "url": url, + "statusCode": status_code, + "cacheMaxAge": cache_value(&root, "cacheMaxAge"), + "staleRevalidate": cache_value(&root, "staleRevalidate"), + "staleError": cache_value(&root, "staleError"), + "valueJson": payload.to_string() + }) + .to_string() +} + +pub(crate) fn normalize_addon_subtitles_json(subtitles_json: &str, resource_url: &str) -> String { + let subtitles = serde_json::from_str::>(subtitles_json) + .unwrap_or_default() + .into_iter() + .filter_map(|mut subtitle| { + let object = subtitle.as_object_mut()?; + let explicit_url = object + .get("url") + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .map(str::to_string); + let attribute_url = object + .get("attributes") + .and_then(Value::as_object) + .and_then(|attributes| attributes.get("url")) + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .map(str::to_string); + let resolved_url = + resolve_resource_asset_url(explicit_url.or(attribute_url), resource_url)?; + + object.insert("url".to_string(), Value::String(resolved_url.clone())); + let lang = object + .get("lang") + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .map(str::to_string); + let attributes = object + .entry("attributes".to_string()) + .or_insert_with(|| json!({})); + if !attributes.is_object() { + *attributes = json!({}); + } + let attributes = attributes.as_object_mut()?; + let attribute_url_is_blank = attributes + .get("url") + .and_then(Value::as_str) + .map(str::trim) + .unwrap_or_default() + .is_empty(); + if attribute_url_is_blank { + attributes.insert("url".to_string(), Value::String(resolved_url)); + } + let languages_empty = attributes + .get("languages") + .and_then(Value::as_array) + .map(Vec::is_empty) + .unwrap_or(true); + if languages_empty { + if let Some(lang) = lang { + attributes.insert("languages".to_string(), json!([lang])); + } + } + Some(subtitle) + }) + .collect::>(); + Value::Array(subtitles).to_string() +} + +fn resolve_resource_asset_url(asset: Option, resource_url: &str) -> Option { + let secure = addon_protocol::prefer_https_asset_url(asset?.as_str())?; + if addon_protocol::is_http_url(&secure) { + return Some(secure); + } + if secure.starts_with('/') { + let scheme_end = resource_url.find("://").map(|index| index + 3)?; + let host_end = resource_url[scheme_end..] + .find('/') + .map(|index| scheme_end + index) + .unwrap_or(resource_url.len()); + return addon_protocol::prefer_https_asset_url(&format!( + "{}{}", + &resource_url[..host_end], + secure + )); + } + let base = resource_url + .rsplit_once('/') + .map(|(base, _)| format!("{base}/")) + .unwrap_or_default(); + addon_protocol::prefer_https_asset_url(&format!("{base}{secure}")) +} + +#[cfg(test)] +mod tests { + use super::{normalize_addon_subtitles_json, parse_addon_resource_result_json}; + use serde_json::Value; + + #[test] + fn stream_payload_keeps_provider_order_and_content() { + let result = parse_addon_resource_result_json( + "stream", + "https://addon.example/stream/movie/tt1.json", + 200, + Some( + r#"{"streams":[{"title":"B"},{"title":"A"}],"cacheMaxAge":3600,"staleRevalidate":120,"staleError":60}"#, + ), + ); + let result: Value = serde_json::from_str(&result).expect("result json"); + let value_json = result + .get("valueJson") + .and_then(Value::as_str) + .expect("value json"); + let streams: Value = serde_json::from_str(value_json).expect("streams"); + + assert_eq!(result.get("kind").and_then(Value::as_str), Some("success")); + assert_eq!( + streams + .get(0) + .and_then(|item| item.get("title")) + .and_then(Value::as_str), + Some("B") + ); + assert_eq!( + streams + .get(1) + .and_then(|item| item.get("title")) + .and_then(Value::as_str), + Some("A") + ); + assert_eq!( + result.get("cacheMaxAge").and_then(Value::as_i64), + Some(3600) + ); + assert_eq!( + result.get("staleRevalidate").and_then(Value::as_i64), + Some(120) + ); + assert_eq!(result.get("staleError").and_then(Value::as_i64), Some(60)); + } + + #[test] + fn empty_and_error_states_are_classified_without_platform_code() { + let empty = + parse_addon_resource_result_json("catalog", "url", 200, Some(r#"{"metas":[]}"#)); + let network = parse_addon_resource_result_json("catalog", "url", 503, Some("{}")); + let parse = parse_addon_resource_result_json("catalog", "url", 200, Some("{")); + + assert_eq!( + serde_json::from_str::(&empty) + .ok() + .and_then(|value| value.get("kind").and_then(Value::as_str).map(str::to_owned)) + .as_deref(), + Some("empty") + ); + assert_eq!( + serde_json::from_str::(&network) + .ok() + .and_then(|value| value.get("kind").and_then(Value::as_str).map(str::to_owned)) + .as_deref(), + Some("network_error") + ); + assert_eq!( + serde_json::from_str::(&parse) + .ok() + .and_then(|value| value.get("kind").and_then(Value::as_str).map(str::to_owned)) + .as_deref(), + Some("parse_error") + ); + } + + #[test] + fn subtitle_payload_is_resolved_without_reordering_valid_entries() { + let subtitles = normalize_addon_subtitles_json( + r#"[ + {"id":"one","url":"/subs/one.vtt","lang":"en","attributes":{"languages":[]}}, + {"id":"drop","attributes":{}}, + {"id":"two","attributes":{"url":"two.srt"}} + ]"#, + "https://addon.example/subtitles/movie/tt1.json", + ); + let subtitles: Value = serde_json::from_str(&subtitles).expect("subtitles"); + + assert_eq!(subtitles.as_array().map(Vec::len), Some(2)); + assert_eq!(subtitles[0]["id"].as_str(), Some("one")); + assert_eq!( + subtitles[0]["url"].as_str(), + Some("https://addon.example/subs/one.vtt") + ); + assert_eq!( + subtitles[0]["attributes"]["languages"][0].as_str(), + Some("en") + ); + assert_eq!(subtitles[1]["id"].as_str(), Some("two")); + assert_eq!( + subtitles[1]["url"].as_str(), + Some("https://addon.example/subtitles/movie/two.srt") + ); + } +} diff --git a/src/addon_store.rs b/src/addon_store.rs new file mode 100644 index 0000000..0223788 --- /dev/null +++ b/src/addon_store.rs @@ -0,0 +1,499 @@ +use regex::Regex; +use serde::Deserialize; +use serde_json::{json, Map, Value}; +use std::sync::OnceLock; + +#[derive(Deserialize)] +struct SearchPolicyRequest { + query: String, + #[serde(rename = "nowMillis")] + now_millis: i64, + #[serde(rename = "cachedAtMillis")] + cached_at_millis: Option, + #[serde(rename = "ttlMillis")] + ttl_millis: i64, +} + +pub(crate) fn addon_store_input_type(raw: &str) -> &'static str { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return "unknown"; + } + + let lower = trimmed.to_ascii_lowercase(); + if lower.contains("manifest.json") { + return "stremio_manifest"; + } + if lower.starts_with("cloudstreamrepo://") + || lower.starts_with("cloudstream://") + || lower.contains("cloudstream") + || lower.contains(".cs3") + || lower.contains("repo.json") + || ((lower.starts_with("http://") || lower.starts_with("https://")) && trimmed.len() > 20) + { + return "cloudstream_repo"; + } + + "search_query" +} + +pub(crate) fn normalize_cloudstream_repo_url(raw: &str) -> String { + let trimmed = raw.trim(); + replace_ascii_prefix( + &replace_ascii_prefix(trimmed, "cloudstreamrepo://", "https://"), + "cloudstream://", + "https://", + ) +} + +pub(crate) fn normalize_plugin_repository_url(raw: &str) -> String { + let trimmed = raw.trim(); + let Some(scheme_end) = trimmed.find("://") else { + return trimmed.to_string(); + }; + let scheme = trimmed[..scheme_end].to_ascii_lowercase(); + if scheme != "http" && scheme != "https" { + return format!("https://{}", &trimmed[scheme_end + 3..]); + } + replace_ascii_prefix(trimmed, "http://", "https://") +} + +pub(crate) fn is_secure_remote_url(raw: &str) -> bool { + let trimmed = raw.trim(); + let lower = trimmed.to_ascii_lowercase(); + if !lower.starts_with("https://") { + return false; + } + let host = trimmed["https://".len()..] + .split(['/', '?', '#']) + .next() + .unwrap_or("") + .trim(); + !host.is_empty() && !host.contains(char::is_whitespace) +} + +pub(crate) fn same_plugin_repository_url(left: &str, right: &str) -> bool { + canonical_url_for_compare(left) == canonical_url_for_compare(right) +} + +fn canonical_url_for_compare(raw: &str) -> String { + let trimmed = raw.trim(); + let lower = trimmed.to_ascii_lowercase(); + if let Some(rest) = lower.strip_prefix("https://") { + format!("https://{}", rest.trim_end_matches('/')) + } else if let Some(rest) = lower.strip_prefix("http://") { + format!("https://{}", rest.trim_end_matches('/')) + } else { + lower.trim_end_matches('/').to_string() + } +} + +pub(crate) fn profile_local_addons_key_json(profile_json: &str) -> Option { + let profile: Value = serde_json::from_str(profile_json).ok()?; + let id = string_field(&profile, "id").unwrap_or_default(); + let email = string_field(&profile, "email").unwrap_or_default(); + Some(format!( + "local_addons_{}", + if id.trim().is_empty() { email } else { id } + )) +} + +pub(crate) fn sanitize_profile_json( + profile_json: &str, + mirrored_addons_json: &str, + merge_mirrored_addons: bool, +) -> Option { + let mut profile: Value = serde_json::from_str(profile_json).ok()?; + let mirrored_addons: Vec = if merge_mirrored_addons { + serde_json::from_str(mirrored_addons_json).unwrap_or_default() + } else { + Vec::new() + }; + + let mut base_addons = string_list_field(&profile, "localAddons"); + base_addons.extend(mirrored_addons); + let cleaned_addons = normalize_distinct_addons(base_addons); + let cleaned_ids = cleaned_addons + .iter() + .map(|addon| crate::addon_protocol::identity(addon)) + .collect::>(); + let cleaned_disabled_addons = + normalize_distinct_addons(string_list_field(&profile, "disabledLocalAddons")) + .into_iter() + .filter(|addon| cleaned_ids.contains(&crate::addon_protocol::identity(addon))) + .collect::>(); + + let object = profile.as_object_mut()?; + object.insert("localAddons".to_string(), json!(cleaned_addons)); + object.insert( + "disabledLocalAddons".to_string(), + json!(cleaned_disabled_addons), + ); + fill_structured_settings(object); + serde_json::to_string(&profile).ok() +} + +fn normalize_distinct_addons(addons: Vec) -> Vec { + let mut seen = std::collections::HashSet::new(); + addons + .into_iter() + .map(|addon| crate::addon_protocol::normalize_manifest_url(&addon)) + .filter(|addon| !addon.trim().is_empty()) + .filter(|addon| seen.insert(crate::addon_protocol::identity(addon))) + .collect() +} + +fn fill_structured_settings(profile: &mut Map) { + insert_object_from_fields( + profile, + "externalAccounts", + &[ + ("traktAccessToken", "traktAccessToken"), + ("traktRefreshToken", "traktRefreshToken"), + ("traktTokenExpiresAt", "traktTokenExpiresAt"), + ("traktLastSyncAt", "traktLastSyncAt"), + ("traktLastSyncedItems", "traktLastSyncedItems"), + ( + "traktLastContinueWatchingCount", + "traktLastContinueWatchingCount", + ), + ("traktLastWatchlistCount", "traktLastWatchlistCount"), + ("malAccessToken", "malAccessToken"), + ("malRefreshToken", "malRefreshToken"), + ("simklAccessToken", "simklAccessToken"), + ], + ); + insert_object_from_fields( + profile, + "addonSettings", + &[ + ("localAddons", "localAddons"), + ("disabledLocalAddons", "disabledLocalAddons"), + ], + ); + insert_object_from_fields( + profile, + "subtitleSettings", + &[ + ("size", "subtitleSize"), + ("color", "subtitleColor"), + ("backgroundColor", "subtitleBackgroundColor"), + ("outlineColor", "subtitleOutlineColor"), + ("textOpacity", "subtitleTextOpacity"), + ("backgroundOpacity", "subtitleBackgroundOpacity"), + ("outlineOpacity", "subtitleOutlineOpacity"), + ("preferredLanguage", "preferredSubtitleLanguage"), + ("secondaryLanguage", "secondarySubtitleLanguage"), + ("shadow", "subtitleShadow"), + ("autoEnable", "autoEnableSubtitles"), + ], + ); + insert_object_from_fields( + profile, + "playbackSettings", + &[ + ("preferredAudioLanguage", "preferredAudioLanguage"), + ("secondaryAudioLanguage", "secondaryAudioLanguage"), + ("stableVolume", "stableVolume"), + ("ambientLight", "ambientLight"), + ("forceSoftwareAudio", "forceSoftwareAudio"), + ("preferredPlayer", "preferredPlayer"), + ("autoSkipIntro", "autoSkipIntro"), + ("autoPlayNextEpisode", "autoPlayNextEpisode"), + ("nextEpisodeThresholdPercent", "nextEpisodeThresholdPercent"), + ("watchedThresholdPercent", "watchedThresholdPercent"), + ("seekForwardSeconds", "seekForwardSeconds"), + ("seekBackwardSeconds", "seekBackwardSeconds"), + ("playerBufferCacheMb", "playerBufferCacheMb"), + ("playerForwardBufferSeconds", "playerForwardBufferSeconds"), + ("playerBackBufferSeconds", "playerBackBufferSeconds"), + ("backgroundPlayback", "backgroundPlayback"), + ("pictureInPicture", "pictureInPicture"), + ("playbackSpeed", "playbackSpeed"), + ("holdToSpeedEnabled", "holdToSpeedEnabled"), + ("holdSpeed", "holdSpeed"), + ("dolbyVisionFallbackMode", "dolbyVisionFallbackMode"), + ("dv7Fallback", "dv7Fallback"), + ("dv7ToDv8Fallback", "dv7ToDv8Fallback"), + ("tunneledPlayback", "tunneledPlayback"), + ("useIntroDb", "useIntroDb"), + ("useAniSkip", "useAniSkip"), + ("defaultQuality", "defaultQuality"), + ("mobileDataUsage", "mobileDataUsage"), + ("hdrPlayback", "hdrPlayback"), + ("resumePlayback", "resumePlayback"), + ("autoplayMode", "autoplayMode"), + ("streamSourceSelectionMode", "streamSourceSelectionMode"), + ("streamSourceRegexPattern", "streamSourceRegexPattern"), + ("tryBingeGroup", "tryBingeGroup"), + ], + ); + insert_object_from_fields( + profile, + "torrentSettings", + &[ + ("wifiOnly", "torrentWifiOnly"), + ("maxConnections", "torrentMaxConnections"), + ("speedPreset", "torrentSpeedPreset"), + ("cachePreset", "torrentCachePreset"), + ], + ); + insert_object_from_fields( + profile, + "appearanceSettings", + &[ + ("language", "language"), + ("cardLayout", "cardLayout"), + ("continueWatchingLayout", "continueWatchingLayout"), + ("continueWatchingArtwork", "continueWatchingArtwork"), + ("continueWatchingEnabled", "continueWatchingEnabled"), + ("appTheme", "appTheme"), + ("accentColorArgb", "accentColorArgb"), + ("cardCornerPreset", "cardCornerPreset"), + ("interfaceDensity", "interfaceDensity"), + ("amoledMode", "amoledMode"), + ("posterWidthPreset", "posterWidthPreset"), + ("posterLandscapeMode", "posterLandscapeMode"), + ("posterHideTitles", "posterHideTitles"), + ("detailEpisodeViewMode", "detailEpisodeViewMode"), + ("detailSeasonSelectorMode", "detailSeasonSelectorMode"), + ("detailSeasonPostersOnHero", "detailSeasonPostersOnHero"), + ("homeSeasonPostersOnHero", "homeSeasonPostersOnHero"), + ("animationsEnabled", "animationsEnabled"), + ("reduceMotion", "reduceMotion"), + ("startPage", "startPage"), + ], + ); + insert_object_from_fields( + profile, + "homeFeedSettings", + &[ + ("heroFeedToggles", "heroFeedToggles"), + ("homeFeedToggles", "homeFeedToggles"), + ("topTenFeedToggles", "topTenFeedToggles"), + ("heroFeedOrder", "heroFeedOrder"), + ("homeFeedOrder", "homeFeedOrder"), + ("showHeroSection", "showHeroSection"), + ("libraryCollections", "libraryCollections"), + ], + ); +} + +fn insert_object_from_fields( + profile: &mut Map, + target: &str, + fields: &[(&str, &str)], +) { + let updates = fields + .iter() + .map(|(target_key, source_key)| { + ( + (*target_key).to_string(), + profile.get(*source_key).cloned().unwrap_or(Value::Null), + ) + }) + .collect::>(); + if profile.get(target).is_some_and(|value| !value.is_null()) { + if let Some(target_object) = profile.get_mut(target).and_then(Value::as_object_mut) { + for (key, value) in updates { + target_object.insert(key, value); + } + } + return; + } + let object = updates.into_iter().collect::>(); + profile.insert(target.to_string(), Value::Object(object)); +} + +fn string_field(value: &Value, key: &str) -> Option { + value.get(key).and_then(Value::as_str).map(str::to_string) +} + +fn string_list_field(value: &Value, key: &str) -> Vec { + value + .get(key) + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .map(str::to_string) + .collect() +} + +pub(crate) fn addon_store_search_policy_json(request_json: &str) -> Option { + let request: SearchPolicyRequest = serde_json::from_str(request_json).ok()?; + let normalized_query = request.query.trim().to_ascii_lowercase(); + if normalized_query.len() < 2 { + return serde_json::to_string(&json!({ + "normalizedQuery": normalized_query, + "url": "", + "useCache": false, + "shouldFetch": false + })) + .ok(); + } + let use_cache = request + .cached_at_millis + .and_then(|cached_at| request.now_millis.checked_sub(cached_at)) + .map(|elapsed| elapsed <= request.ttl_millis) + .unwrap_or(false); + serde_json::to_string(&json!({ + "normalizedQuery": normalized_query, + "url": format!( + "https://stremio-addons.net/addons?query={}", + form_urlencode(&normalized_query) + ), + "useCache": use_cache, + "shouldFetch": !use_cache + })) + .ok() +} + +fn manifest_url_regex() -> &'static Regex { + static REGEX: OnceLock = OnceLock::new(); + REGEX.get_or_init(|| { + Regex::new(r#"https?://[^"'\\ ]+manifest\.json[^"'\\ ]*"#).expect("valid manifest url regex") + }) +} + +pub(crate) fn extract_addon_manifest_url(detail_text: &str) -> Option { + let unescaped_text = detail_text.replace("\\/", "/").replace("\\u0026", "&"); + manifest_url_regex() + .find(&unescaped_text) + .map(|match_| match_.as_str().trim_end_matches('\\').to_string()) +} + +fn replace_ascii_prefix(value: &str, prefix: &str, replacement: &str) -> String { + if value.len() >= prefix.len() && value[..prefix.len()].eq_ignore_ascii_case(prefix) { + format!("{replacement}{}", &value[prefix.len()..]) + } else { + value.to_string() + } +} + +fn form_urlencode(value: &str) -> String { + let mut encoded = String::with_capacity(value.len()); + for byte in value.bytes() { + let keep = byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'*'); + if keep { + encoded.push(byte as char); + } else if byte == b' ' { + encoded.push('+'); + } else { + encoded.push_str(&format!("%{byte:02X}")); + } + } + encoded +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detects_manifest_before_generic_https_repo_rule() { + assert_eq!( + "stremio_manifest", + addon_store_input_type("https://addon.example/manifest.json") + ); + assert_eq!( + "cloudstream_repo", + addon_store_input_type("cloudstreamrepo://example.com/repo.json") + ); + assert_eq!("search_query", addon_store_input_type("cinemeta")); + } + + #[test] + fn plans_search_url_and_cache_use() { + let json = addon_store_search_policy_json( + r#"{"query":"Game of Thrones","nowMillis":2000,"cachedAtMillis":1500,"ttlMillis":1000}"#, + ) + .unwrap(); + assert!(json.contains(r#""normalizedQuery":"game of thrones""#)); + assert!(json.contains(r#""url":"https://stremio-addons.net/addons?query=game+of+thrones""#)); + assert!(json.contains(r#""useCache":true"#)); + } + + #[test] + fn extracts_escaped_manifest_url_from_detail_page() { + assert_eq!( + Some("https://addon.example/root/manifest.json?x=1&y=2".to_string()), + extract_addon_manifest_url( + r#""#, + ) + ); + } + + #[test] + fn plugin_repository_url_policy_normalizes_and_requires_https() { + assert_eq!( + normalize_plugin_repository_url("cloudstream://example.com/repo.json"), + "https://example.com/repo.json" + ); + assert_eq!( + normalize_plugin_repository_url("http://example.com/repo.json"), + "https://example.com/repo.json" + ); + assert!(is_secure_remote_url("https://example.com/repo.json")); + assert!(!is_secure_remote_url("http://example.com/repo.json")); + assert!(same_plugin_repository_url( + "http://example.com/repo.json/", + "https://EXAMPLE.com/repo.json" + )); + } + + #[test] + fn sanitize_profile_merges_and_deduplicates_local_addons() { + let sanitized = sanitize_profile_json( + r#"{"id":"p1","email":"u@example.com","localAddons":["http://a.example/manifest.json"],"disabledLocalAddons":["https://b.example/manifest.json","https://missing.example/manifest.json"],"language":"tr"}"#, + r#"["https://a.example/manifest.json","https://b.example/manifest.json"]"#, + true, + ) + .and_then(|json| serde_json::from_str::(&json).ok()) + .expect("profile"); + + assert_eq!( + sanitized + .get("localAddons") + .and_then(Value::as_array) + .map(Vec::len), + Some(2) + ); + assert_eq!( + sanitized + .get("disabledLocalAddons") + .and_then(Value::as_array) + .map(Vec::len), + Some(1) + ); + assert_eq!( + sanitized + .get("appearanceSettings") + .and_then(|value| value.get("language")) + .and_then(Value::as_str), + Some("tr") + ); + } + + #[test] + fn sanitize_profile_syncs_home_feed_settings_from_top_level_fields() { + let sanitized = sanitize_profile_json( + r#"{"id":"p1","email":"u@example.com","localAddons":["https://a.example/manifest.json"],"libraryCollections":[{"id":"new","title":"New"}],"homeFeedSettings":{"libraryCollections":[{"id":"old","title":"Old"}],"homeFeedToggles":["old"]},"homeFeedToggles":[]}"#, + r#"[]"#, + false, + ) + .and_then(|json| serde_json::from_str::(&json).ok()) + .expect("profile"); + + assert_eq!( + sanitized["homeFeedSettings"]["libraryCollections"][0]["id"], + "new" + ); + assert_eq!( + sanitized["homeFeedSettings"]["homeFeedToggles"] + .as_array() + .map(Vec::len), + Some(0) + ); + } +} diff --git a/src/addon_transport/mod.rs b/src/addon_transport/mod.rs new file mode 100644 index 0000000..7801e66 --- /dev/null +++ b/src/addon_transport/mod.rs @@ -0,0 +1,15 @@ +use crate::types::addon::AddonManifest; + +/// Mirrors stremio-core's `AddonTransport` trait. +/// Rust should never implement this by doing HTTP directly — the platform (Kotlin/JS) executes HTTP and passes results back via completeEffect. +pub trait AddonTransport { + fn manifest(&self) -> Option<&AddonManifest>; + + fn resource( + &self, + resource: &str, + type_: &str, + id: &str, + extra: Option<&str>, + ) -> Result; +} diff --git a/src/app_state.rs b/src/app_state.rs new file mode 100644 index 0000000..a30c3e8 --- /dev/null +++ b/src/app_state.rs @@ -0,0 +1,493 @@ +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Mutex, OnceLock}; + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AppCoreState { + #[serde(default)] + pub home: HomeState, + #[serde(default)] + pub home_search: HomeSearchState, + #[serde(default)] + pub billboard: BillboardState, + #[serde(default)] + pub discover: DiscoverState, + #[serde(default)] + pub calendar: CalendarState, + #[serde(default)] + pub library: LibraryState, + #[serde(default)] + pub player: PlayerCoreState, + #[serde(flatten)] + pub extra: HashMap, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BillboardState { + #[serde(default)] + pub error: Value, + #[serde(default)] + pub pool: Value, + #[serde(default)] + pub index: i64, + #[serde(default)] + pub movie: Value, + #[serde(default)] + pub logo: Value, + #[serde(default)] + pub watchlist: bool, + #[serde(default)] + pub next_episode: Value, + #[serde(default)] + pub trailer_url: Value, +} + +impl Default for BillboardState { + fn default() -> Self { + Self { + error: Value::Null, + pool: json!([]), + index: 0, + movie: Value::Null, + logo: Value::Null, + watchlist: false, + next_episode: Value::Null, + trailer_url: Value::Null, + } + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DiscoverState { + #[serde(default)] + pub results: Value, + #[serde(default)] + pub is_loading: bool, + #[serde(default)] + pub genres: Value, + #[serde(default)] + pub catalogs: Value, +} + +impl Default for DiscoverState { + fn default() -> Self { + Self { + results: json!([]), + is_loading: false, + genres: json!([]), + catalogs: json!([]), + } + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CalendarState { + #[serde(default)] + pub items: Value, + #[serde(default)] + pub is_loading: bool, +} + +impl Default for CalendarState { + fn default() -> Self { + Self { + items: json!([]), + is_loading: false, + } + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LibraryState { + #[serde(default)] + pub ui_state: Value, +} + +impl Default for LibraryState { + fn default() -> Self { + Self { + ui_state: json!({}), + } + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct HomeState { + #[serde(default)] + pub categories: Value, + #[serde(default)] + pub is_loading: bool, + #[serde(default = "default_home_filter")] + pub current_filter: String, + #[serde(default)] + pub is_direct_loading: bool, + #[serde(default)] + pub trakt_continue_watching_last_updated_at: i64, + #[serde(default)] + pub user_addons: Value, + #[serde(default)] + pub watchlist: Value, + #[serde(default)] + pub liked_items: Value, + #[serde(default)] + pub active_profile: Value, + #[serde(default)] + pub current_watchlist: Value, + #[serde(default)] + pub external_continue_watching: Value, + #[serde(default)] + pub trakt_watched_state: Value, +} + +impl Default for HomeState { + fn default() -> Self { + Self { + categories: json!([]), + is_loading: false, + current_filter: default_home_filter(), + is_direct_loading: false, + trakt_continue_watching_last_updated_at: 0, + user_addons: json!([]), + watchlist: json!([]), + liked_items: json!([]), + active_profile: Value::Null, + current_watchlist: json!([]), + external_continue_watching: json!([]), + trakt_watched_state: json!({}), + } + } +} + +fn default_home_filter() -> String { + "all".to_string() +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct HomeSearchState { + #[serde(default)] + pub search_results: Value, + #[serde(default)] + pub search_rows: Value, + #[serde(default)] + pub search_history: Value, + #[serde(default)] + pub focused_movie: Value, + #[serde(default)] + pub focused_movie_trailer_url: Value, + #[serde(default)] + pub preview_url: Value, +} + +impl Default for HomeSearchState { + fn default() -> Self { + Self { + search_results: json!([]), + search_rows: json!([]), + search_history: json!([]), + focused_movie: Value::Null, + focused_movie_trailer_url: Value::Null, + preview_url: Value::Null, + } + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PlayerCoreState { + #[serde(default)] + pub current_video_id: Value, + #[serde(default)] + pub current_stream_index: i64, + #[serde(default)] + pub last_saved_position: i64, + #[serde(default)] + pub should_apply_initial_progress: bool, + #[serde(default)] + pub playback_ended: bool, + #[serde(default)] + pub has_started_playing: bool, + #[serde(default)] + pub is_video_rendered: bool, + #[serde(default = "default_buffering")] + pub is_buffering: bool, + #[serde(flatten)] + pub extra: HashMap, +} + +impl Default for PlayerCoreState { + fn default() -> Self { + Self { + current_video_id: Value::Null, + current_stream_index: 0, + last_saved_position: 0, + should_apply_initial_progress: false, + playback_ended: false, + has_started_playing: false, + is_video_rendered: false, + is_buffering: true, + extra: HashMap::new(), + } + } +} + +fn default_buffering() -> bool { + true +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct AppCoreAction { + #[serde(rename = "type")] + action_type: String, + #[serde(default)] + value: Value, + #[serde(default)] + video_id: Value, +} + +static NEXT_HANDLE: AtomicU64 = AtomicU64::new(1); +static STORE: OnceLock>> = OnceLock::new(); + +fn store() -> &'static Mutex> { + STORE.get_or_init(|| Mutex::new(HashMap::new())) +} + +pub fn create_app_core_state(initial_json: &str) -> u64 { + let state = serde_json::from_str(initial_json).unwrap_or_default(); + if let Ok(mut states) = store().lock() { + let handle = NEXT_HANDLE.fetch_add(1, Ordering::Relaxed); + states.insert(handle, state); + handle + } else { + 0 + } +} + +pub fn destroy_app_core_state(handle: u64) -> bool { + store() + .lock() + .map(|mut states| states.remove(&handle).is_some()) + .unwrap_or(false) +} + +pub fn app_core_state_json(handle: u64) -> Option { + store().lock().ok().and_then(|states| { + states + .get(&handle) + .and_then(|state| serde_json::to_string(state).ok()) + }) +} + +pub fn app_core_dispatch_json(handle: u64, action_json: &str) -> Option { + let action: AppCoreAction = serde_json::from_str(action_json).ok()?; + let mut states = store().lock().ok()?; + let state = states.get_mut(&handle)?; + reduce(state, action); + serde_json::to_string(state).ok() +} + +fn reduce(state: &mut AppCoreState, action: AppCoreAction) { + match action.action_type.as_str() { + "setHomeCategories" => state.home.categories = array_or_empty(action.value), + "setHomeLoading" => state.home.is_loading = action.value.as_bool().unwrap_or(false), + "setHomeCurrentFilter" => { + state.home.current_filter = action + .value + .as_str() + .filter(|value| !value.is_empty()) + .unwrap_or("all") + .to_string() + } + "setHomeDirectLoading" => { + state.home.is_direct_loading = action.value.as_bool().unwrap_or(false) + } + "setTraktContinueWatchingLastUpdatedAt" => { + state.home.trakt_continue_watching_last_updated_at = action.value.as_i64().unwrap_or(0) + } + "setUserAddons" => state.home.user_addons = array_or_empty(action.value), + "setWatchlist" => state.home.watchlist = array_or_empty(action.value), + "setLikedItems" => state.home.liked_items = array_or_empty(action.value), + "setActiveProfile" => state.home.active_profile = action.value, + "setCurrentWatchlist" => state.home.current_watchlist = array_or_empty(action.value), + "setExternalContinueWatching" => { + state.home.external_continue_watching = array_or_empty(action.value) + } + "setTraktWatchedState" => state.home.trakt_watched_state = action.value, + "setSearchResults" => state.home_search.search_results = array_or_empty(action.value), + "setSearchRows" => state.home_search.search_rows = array_or_empty(action.value), + "setSearchHistory" => state.home_search.search_history = array_or_empty(action.value), + "setFocusedMovie" => state.home_search.focused_movie = action.value, + "setFocusedMovieTrailerUrl" => state.home_search.focused_movie_trailer_url = action.value, + "setPreviewUrl" => state.home_search.preview_url = action.value, + "setBillboardError" => state.billboard.error = action.value, + "setBillboardPool" => state.billboard.pool = array_or_empty(action.value), + "setBillboardIndex" => state.billboard.index = action.value.as_i64().unwrap_or(0).max(0), + "setBillboardMovie" => state.billboard.movie = action.value, + "setBillboardLogo" => state.billboard.logo = action.value, + "setBillboardWatchlist" => { + state.billboard.watchlist = action.value.as_bool().unwrap_or(false) + } + "setBillboardNextEpisode" => state.billboard.next_episode = action.value, + "setBillboardTrailerUrl" => state.billboard.trailer_url = action.value, + "setDiscoverResults" => state.discover.results = array_or_empty(action.value), + "setDiscoverLoading" => state.discover.is_loading = action.value.as_bool().unwrap_or(false), + "setDiscoverGenres" => state.discover.genres = array_or_empty(action.value), + "setDiscoverCatalogs" => state.discover.catalogs = array_or_empty(action.value), + "setCalendarItems" => state.calendar.items = array_or_empty(action.value), + "setCalendarLoading" => state.calendar.is_loading = action.value.as_bool().unwrap_or(false), + "setLibraryUiState" => state.library.ui_state = action.value, + "playerResetForEpisode" => reset_player_for_episode(&mut state.player, action.video_id), + _ => {} + } +} + +fn array_or_empty(value: Value) -> Value { + if value.is_array() { + value + } else { + json!([]) + } +} + +fn reset_player_for_episode(player: &mut PlayerCoreState, video_id: Value) { + player.current_video_id = video_id; + player.current_stream_index = 0; + player.last_saved_position = 0; + player.should_apply_initial_progress = false; + player.playback_ended = false; + player.has_started_playing = false; + player.is_video_rendered = false; + player.is_buffering = true; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reducer_updates_home_search_state_without_reordering_payloads() { + let handle = create_app_core_state( + r#"{"home":{"categories":[{"id":"c1"}]},"homeSearch":{"searchHistory":[{"id":"tt1"}]}}"#, + ); + + let snapshot = app_core_dispatch_json( + handle, + r#"{"type":"setSearchResults","value":[{"id":"tt2"},{"id":"tt1"}]}"#, + ) + .unwrap(); + let value: Value = serde_json::from_str(&snapshot).unwrap(); + + assert_eq!( + value["homeSearch"]["searchResults"], + json!([{"id":"tt2"},{"id":"tt1"}]) + ); + assert_eq!(value["home"]["categories"], json!([{"id":"c1"}])); + assert_eq!(value["homeSearch"]["searchHistory"], json!([{"id":"tt1"}])); + assert!(destroy_app_core_state(handle)); + } + + #[test] + fn reducer_owns_home_shell_state() { + let handle = create_app_core_state("{}"); + + let snapshot = app_core_dispatch_json( + handle, + r#"{"type":"setHomeCategories","value":[{"id":"continue_watching"},{"id":"popular"}]}"#, + ) + .unwrap(); + let value: Value = serde_json::from_str(&snapshot).unwrap(); + assert_eq!( + value["home"]["categories"], + json!([{"id":"continue_watching"},{"id":"popular"}]) + ); + + let snapshot = app_core_dispatch_json( + handle, + r#"{"type":"setHomeCurrentFilter","value":"movies"}"#, + ) + .unwrap(); + let value: Value = serde_json::from_str(&snapshot).unwrap(); + assert_eq!(value["home"]["currentFilter"], json!("movies")); + + let snapshot = + app_core_dispatch_json(handle, r#"{"type":"setHomeLoading","value":true}"#).unwrap(); + let value: Value = serde_json::from_str(&snapshot).unwrap(); + assert_eq!(value["home"]["isLoading"], json!(true)); + assert_eq!(value["home"]["currentFilter"], json!("movies")); + assert!(destroy_app_core_state(handle)); + } + + #[test] + fn reducer_owns_home_feature_state_branches() { + let handle = create_app_core_state("{}"); + + let snapshot = + app_core_dispatch_json(handle, r#"{"type":"setBillboardIndex","value":2}"#).unwrap(); + let value: Value = serde_json::from_str(&snapshot).unwrap(); + assert_eq!(value["billboard"]["index"], json!(2)); + + let snapshot = app_core_dispatch_json( + handle, + r#"{"type":"setDiscoverResults","value":[{"id":"tt1"},{"id":"tt2"}]}"#, + ) + .unwrap(); + let value: Value = serde_json::from_str(&snapshot).unwrap(); + assert_eq!( + value["discover"]["results"], + json!([{"id":"tt1"},{"id":"tt2"}]) + ); + + let snapshot = app_core_dispatch_json( + handle, + r#"{"type":"setCalendarItems","value":[{"title":"Episode"}]}"#, + ) + .unwrap(); + let value: Value = serde_json::from_str(&snapshot).unwrap(); + assert_eq!(value["calendar"]["items"], json!([{"title":"Episode"}])); + + let snapshot = app_core_dispatch_json( + handle, + r#"{"type":"setLibraryUiState","value":{"isLoading":false,"lastLoadedProfileKey":"profile"}}"#, + ) + .unwrap(); + let value: Value = serde_json::from_str(&snapshot).unwrap(); + assert_eq!( + value["library"]["uiState"]["lastLoadedProfileKey"], + json!("profile") + ); + assert!(destroy_app_core_state(handle)); + } + + #[test] + fn reducer_resets_player_episode_state_like_kotlin_state_holder() { + let handle = create_app_core_state( + r#"{"player":{"currentStreamIndex":3,"lastSavedPosition":9200,"playbackEnded":true,"hasStartedPlaying":true,"isVideoRendered":true,"isBuffering":false}}"#, + ); + + let snapshot = app_core_dispatch_json( + handle, + r#"{"type":"playerResetForEpisode","videoId":"tt123:1:2"}"#, + ) + .unwrap(); + let value: Value = serde_json::from_str(&snapshot).unwrap(); + + assert_eq!(value["player"]["currentVideoId"], json!("tt123:1:2")); + assert_eq!(value["player"]["currentStreamIndex"], json!(0)); + assert_eq!(value["player"]["lastSavedPosition"], json!(0)); + assert_eq!(value["player"]["shouldApplyInitialProgress"], json!(false)); + assert_eq!(value["player"]["playbackEnded"], json!(false)); + assert_eq!(value["player"]["hasStartedPlaying"], json!(false)); + assert_eq!(value["player"]["isVideoRendered"], json!(false)); + assert_eq!(value["player"]["isBuffering"], json!(true)); + assert!(destroy_app_core_state(handle)); + } +} diff --git a/src/bindings/jni.rs b/src/bindings/jni.rs new file mode 100644 index 0000000..cacfbfe --- /dev/null +++ b/src/bindings/jni.rs @@ -0,0 +1,1932 @@ +use crate::addon_protocol::*; +use crate::addon_resource::*; +use crate::addon_store::*; +use crate::app_state::*; +use crate::calendar_plan::*; +use crate::content_identity::*; +use crate::core_contract::*; +use crate::data_policy::*; +use crate::discovery_plan::*; +use crate::dolby_vision_rpu::*; +use crate::external_sync::*; +use crate::headless_adapter_plan::*; +use crate::headless_engine::*; +use crate::home_ranking::*; +use crate::library_state::*; +use crate::offline_download::*; +use crate::player_flow::*; +use crate::player_policy::*; +use crate::player_scrobble::*; +use crate::profile_contract::*; +use crate::profile_prefs::*; +use crate::repository_flow::*; +use crate::search_plan::*; +use crate::stream_policy::*; +use crate::watchlist_plan::*; +use jni::objects::JClass; +pub(crate) use jni::objects::JString; +use jni::sys::{jboolean, jfloat, jint, jlong, jstring}; +pub(crate) use jni::JNIEnv; +use serde_json::json; +use std::ptr; + +pub(crate) type JBoolean = jboolean; +pub(crate) type JFloat = jfloat; +pub(crate) type JInt = jint; +pub(crate) type JLong = jlong; +pub(crate) type JObject<'local> = JClass<'local>; +pub(crate) type JStringReturn = jstring; + +pub(crate) fn read_jstring(env: &mut JNIEnv<'_>, value: &JString<'_>) -> Option { + env.get_string(value) + .ok() + .map(|value| value.to_string_lossy().into_owned()) +} + +pub(crate) fn write_jstring(env: &mut JNIEnv<'_>, value: Option) -> JStringReturn { + let Some(value) = value else { + return ptr::null_mut(); + }; + env.new_string(value) + .map(JString::into_raw) + .unwrap_or_else(|_| ptr::null_mut()) +} + +macro_rules! string_fn { + ($name:ident, $body:expr) => { + #[no_mangle] + pub unsafe extern "system" fn $name( + mut env: JNIEnv<'_>, + _class: JObject<'_>, + input: JString<'_>, + ) -> JStringReturn { + let output = read_jstring(&mut env, &input).map($body); + write_jstring(&mut env, output) + } + }; +} + +string_fn!( + Java_com_fluxa_app_core_rust_FluxaCoreNative_normalizeManifestUrlNative, + |value: String| normalize_manifest_url(&value) +); +string_fn!( + Java_com_fluxa_app_core_rust_FluxaCoreNative_identityNative, + |value: String| identity(&value) +); +string_fn!( + Java_com_fluxa_app_core_rust_FluxaCoreNative_baseUrlNative, + |value: String| base_url(&value) +); +string_fn!( + Java_com_fluxa_app_core_rust_FluxaCoreNative_preferHttpsAssetUrlNative, + |value: String| prefer_https_asset_url(&value).unwrap_or_default() +); +string_fn!( + Java_com_fluxa_app_core_rust_FluxaCoreNative_addonStoreInputTypeNative, + |value: String| addon_store_input_type(&value).to_string() +); +string_fn!( + Java_com_fluxa_app_core_rust_FluxaCoreNative_normalizeCloudstreamRepoUrlNative, + |value: String| normalize_cloudstream_repo_url(&value) +); +string_fn!( + Java_com_fluxa_app_core_rust_FluxaCoreNative_normalizePluginRepositoryUrlNative, + |value: String| normalize_plugin_repository_url(&value) +); +string_fn!( + Java_com_fluxa_app_core_rust_FluxaCoreNative_extractAddonManifestUrlNative, + |value: String| extract_addon_manifest_url(&value).unwrap_or_default() +); +string_fn!( + Java_com_fluxa_app_core_rust_FluxaCoreNative_offlineDownloadPlanJsonNative, + |value: String| offline_download_plan_json(&value).unwrap_or_default() +); +string_fn!( + Java_com_fluxa_app_core_rust_FluxaCoreNative_profileSafePrefsJsonNative, + |value: String| profile_safe_prefs_json(&value).unwrap_or_default() +); +string_fn!( + Java_com_fluxa_app_core_rust_FluxaCoreNative_profileLocalAddonsKeyNative, + |value: String| profile_local_addons_key_json(&value).unwrap_or_default() +); +string_fn!( + Java_com_fluxa_app_core_rust_FluxaCoreNative_cacheEntryPolicyJsonNative, + |value: String| cache_entry_policy_json(&value).unwrap_or_default() +); +string_fn!( + Java_com_fluxa_app_core_rust_FluxaCoreNative_cacheTrimPolicyJsonNative, + |value: String| cache_trim_policy_json(&value).unwrap_or_default() +); +string_fn!( + Java_com_fluxa_app_core_rust_FluxaCoreNative_dataFailurePolicyJsonNative, + |value: String| data_failure_policy_json(&value).unwrap_or_default() +); +string_fn!( + Java_com_fluxa_app_core_rust_FluxaCoreNative_addonStoreSearchPolicyJsonNative, + |value: String| addon_store_search_policy_json(&value).unwrap_or_default() +); +string_fn!( + Java_com_fluxa_app_core_rust_FluxaCoreNative_playerTrackStateJsonNative, + |value: String| player_track_state_json(&value).unwrap_or_default() +); +string_fn!( + Java_com_fluxa_app_core_rust_FluxaCoreNative_repositoryMetaDetailPlanJsonNative, + |value: String| repository_meta_detail_plan_json(&value).unwrap_or_default() +); +string_fn!( + Java_com_fluxa_app_core_rust_FluxaCoreNative_manifestFetchDecisionJsonNative, + |value: String| manifest_fetch_decision_json(&value).unwrap_or_default() +); +string_fn!( + Java_com_fluxa_app_core_rust_FluxaCoreNative_addonResourceRequestPlanJsonNative, + |value: String| addon_resource_request_plan_json(&value).unwrap_or_default() +); +string_fn!( + Java_com_fluxa_app_core_rust_FluxaCoreNative_headlessProviderAvailabilityPlanJsonNative, + |value: String| provider_availability_plan_json(&value).unwrap_or_default() +); +string_fn!( + Java_com_fluxa_app_core_rust_FluxaCoreNative_headlessDetailStreamResultPlanJsonNative, + |value: String| detail_stream_result_plan_json(&value).unwrap_or_default() +); +string_fn!( + Java_com_fluxa_app_core_rust_FluxaCoreNative_headlessPrefetchDetailStreamsPlanJsonNative, + |value: String| prefetch_detail_streams_plan_json(&value).unwrap_or_default() +); +string_fn!( + Java_com_fluxa_app_core_rust_FluxaCoreNative_dolbyVisionRpuInfoJsonNative, + |value: String| dolby_vision_rpu_info_json(&value).unwrap_or_default() +); +string_fn!( + Java_com_fluxa_app_core_rust_FluxaCoreNative_dolbyVisionConvertRpuJsonNative, + |value: String| dolby_vision_convert_rpu_json(&value).unwrap_or_default() +); + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_repositorySeasonVideosJsonNative( + mut env: JNIEnv<'_>, + _class: JObject<'_>, + meta_detail_json: JString<'_>, + season_number: JInt, +) -> JStringReturn { + let output = read_jstring(&mut env, &meta_detail_json) + .map(|meta_detail_json| repository_season_videos_json(&meta_detail_json, season_number)); + write_jstring(&mut env, output) +} + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_sanitizeProfileJsonNative( + mut env: JNIEnv<'_>, + _class: JObject<'_>, + profile_json: JString<'_>, + mirrored_addons_json: JString<'_>, + merge_mirrored_addons: JBoolean, +) -> JStringReturn { + let output = read_jstring(&mut env, &profile_json).and_then(|profile_json| { + sanitize_profile_json( + &profile_json, + &read_jstring(&mut env, &mirrored_addons_json)?, + merge_mirrored_addons != 0, + ) + }); + write_jstring(&mut env, output) +} + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_pluginIsSecureRemoteUrlNative( + mut env: JNIEnv<'_>, + _class: JObject<'_>, + url: JString<'_>, +) -> JBoolean { + if read_jstring(&mut env, &url) + .map(|url| is_secure_remote_url(&url)) + .unwrap_or(false) + { + 1 + } else { + 0 + } +} + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_pluginSameRepositoryUrlNative( + mut env: JNIEnv<'_>, + _class: JObject<'_>, + left: JString<'_>, + right: JString<'_>, +) -> JBoolean { + if read_jstring(&mut env, &left) + .and_then(|left| { + Some(same_plugin_repository_url( + &left, + &read_jstring(&mut env, &right)?, + )) + }) + .unwrap_or(false) + { + 1 + } else { + 0 + } +} + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_addonStreamsWithProviderJsonNative( + mut env: JNIEnv<'_>, + _class: JObject<'_>, + streams_json: JString<'_>, + addon_name: JString<'_>, +) -> JStringReturn { + let output = read_jstring(&mut env, &streams_json).and_then(|streams_json| { + Some(addon_streams_with_provider_json( + &streams_json, + &read_jstring(&mut env, &addon_name)?, + )) + }); + write_jstring(&mut env, output) +} + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_createAppCoreStateNative( + mut env: JNIEnv<'_>, + _class: JObject<'_>, + initial_json: JString<'_>, +) -> JLong { + read_jstring(&mut env, &initial_json) + .map(|initial_json| create_app_core_state(&initial_json) as JLong) + .unwrap_or(0) +} + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_destroyAppCoreStateNative( + _env: JNIEnv<'_>, + _class: JObject<'_>, + handle: JLong, +) -> JBoolean { + if handle > 0 && destroy_app_core_state(handle as u64) { + 1 + } else { + 0 + } +} + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_appCoreStateJsonNative( + mut env: JNIEnv<'_>, + _class: JObject<'_>, + handle: JLong, +) -> JStringReturn { + let output = if handle > 0 { + app_core_state_json(handle as u64) + } else { + None + }; + write_jstring(&mut env, output) +} + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_appCoreDispatchJsonNative( + mut env: JNIEnv<'_>, + _class: JObject<'_>, + handle: JLong, + action_json: JString<'_>, +) -> JStringReturn { + let output = if handle > 0 { + read_jstring(&mut env, &action_json) + .and_then(|action_json| app_core_dispatch_json(handle as u64, &action_json)) + } else { + None + }; + write_jstring(&mut env, output) +} + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_createHeadlessEngineNative( + mut env: JNIEnv<'_>, + _class: JObject<'_>, + initial_json: JString<'_>, +) -> JLong { + read_jstring(&mut env, &initial_json) + .map(|initial_json| create_headless_engine(&initial_json) as JLong) + .unwrap_or(0) +} + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_destroyHeadlessEngineNative( + _env: JNIEnv<'_>, + _class: JObject<'_>, + handle: JLong, +) -> JBoolean { + if handle > 0 && destroy_headless_engine(handle as u64) { + 1 + } else { + 0 + } +} + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_headlessEngineSnapshotJsonNative( + mut env: JNIEnv<'_>, + _class: JObject<'_>, + handle: JLong, +) -> JStringReturn { + let output = if handle > 0 { + headless_engine_snapshot_json(handle as u64) + } else { + None + }; + write_jstring(&mut env, output) +} + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_headlessEngineDispatchJsonNative( + mut env: JNIEnv<'_>, + _class: JObject<'_>, + handle: JLong, + action_json: JString<'_>, +) -> JStringReturn { + let output = if handle > 0 { + read_jstring(&mut env, &action_json) + .and_then(|action_json| headless_engine_dispatch_json(handle as u64, &action_json)) + } else { + None + }; + write_jstring(&mut env, output) +} + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_headlessEngineCompleteEffectJsonNative( + mut env: JNIEnv<'_>, + _class: JObject<'_>, + handle: JLong, + result_json: JString<'_>, +) -> JStringReturn { + let output = if handle > 0 { + read_jstring(&mut env, &result_json).and_then(|result_json| { + headless_engine_complete_effect_json(handle as u64, &result_json) + }) + } else { + None + }; + write_jstring(&mut env, output) +} + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_coreCapabilitiesJsonNative( + mut env: JNIEnv<'_>, + _class: JObject<'_>, + portable: JBoolean, +) -> JStringReturn { + write_jstring(&mut env, Some(core_capabilities_json(portable != 0))) +} + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_headlessDirectPlaybackPolicyJsonNative( + mut env: JNIEnv<'_>, + _class: JObject<'_>, +) -> JStringReturn { + write_jstring(&mut env, Some(direct_playback_policy_json())) +} + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_manifestCandidatesJsonNative( + mut env: JNIEnv<'_>, + _class: JObject<'_>, + input: JString<'_>, +) -> JStringReturn { + let output = read_jstring(&mut env, &input) + .and_then(|value| serde_json::to_string(&manifest_candidates(&value)).ok()); + write_jstring(&mut env, output) +} + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_manifestFetchPlanJsonNative( + mut env: JNIEnv<'_>, + _class: JObject<'_>, + input: JString<'_>, +) -> JStringReturn { + let output = read_jstring(&mut env, &input).and_then(|value| manifest_fetch_plan_json(&value)); + write_jstring(&mut env, output) +} + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_buildResourceUrlNative( + mut env: JNIEnv<'_>, + _class: JObject<'_>, + transport_url: JString<'_>, + resource: JString<'_>, + content_type: JString<'_>, + id: JString<'_>, + extra_json: JString<'_>, +) -> JStringReturn { + let output = read_jstring(&mut env, &transport_url).and_then(|transport_url| { + Some(build_resource_url( + &transport_url, + &read_jstring(&mut env, &resource)?, + &read_jstring(&mut env, &content_type)?, + &read_jstring(&mut env, &id)?, + read_jstring(&mut env, &extra_json).as_deref(), + )) + }); + write_jstring(&mut env, output) +} + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_parseManifestJsonNative( + mut env: JNIEnv<'_>, + _class: JObject<'_>, + body: JString<'_>, + transport_url: JString<'_>, + unknown_name: JString<'_>, +) -> JStringReturn { + let output = read_jstring(&mut env, &body).and_then(|body| { + parse_manifest( + &body, + &read_jstring(&mut env, &transport_url)?, + &read_jstring(&mut env, &unknown_name)?, + ) + }); + write_jstring(&mut env, output) +} + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_resolveManifestAssetsJsonNative( + mut env: JNIEnv<'_>, + _class: JObject<'_>, + descriptor_json: JString<'_>, +) -> JStringReturn { + let output = read_jstring(&mut env, &descriptor_json) + .and_then(|descriptor_json| resolve_manifest_assets_json(&descriptor_json)); + write_jstring(&mut env, output) +} + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_mergeLiveManifestJsonNative( + mut env: JNIEnv<'_>, + _class: JObject<'_>, + descriptor_json: JString<'_>, + live_json: JString<'_>, + unknown_name: JString<'_>, +) -> JStringReturn { + let output = read_jstring(&mut env, &descriptor_json).and_then(|descriptor_json| { + merge_live_manifest_json( + &descriptor_json, + read_jstring(&mut env, &live_json) + .filter(|value| !value.trim().is_empty()) + .as_deref(), + &read_jstring(&mut env, &unknown_name)?, + ) + }); + write_jstring(&mut env, output) +} + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_supportsResourceNative( + mut env: JNIEnv<'_>, + _class: JObject<'_>, + manifest_json: JString<'_>, + resource_name: JString<'_>, + content_type: JString<'_>, + id: JString<'_>, +) -> JBoolean { + let result = read_jstring(&mut env, &manifest_json) + .and_then(|manifest_json| { + Some(supports_resource( + &manifest_json, + &read_jstring(&mut env, &resource_name)?, + read_jstring(&mut env, &content_type) + .filter(|value| !value.is_empty()) + .as_deref(), + read_jstring(&mut env, &id) + .filter(|value| !value.is_empty()) + .as_deref(), + )) + }) + .unwrap_or(false); + if result { + 1 + } else { + 0 + } +} + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_catalogSupportsExtraNative( + mut env: JNIEnv<'_>, + _class: JObject<'_>, + catalog_json: JString<'_>, + extra_name: JString<'_>, +) -> JBoolean { + let result = read_jstring(&mut env, &catalog_json) + .and_then(|catalog_json| { + Some(catalog_supports_extra( + &catalog_json, + &read_jstring(&mut env, &extra_name)?, + )) + }) + .unwrap_or(false); + if result { + 1 + } else { + 0 + } +} + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_catalogRequiresExtraNative( + mut env: JNIEnv<'_>, + _class: JObject<'_>, + catalog_json: JString<'_>, + extra_name: JString<'_>, +) -> JBoolean { + let result = read_jstring(&mut env, &catalog_json) + .and_then(|catalog_json| { + Some(catalog_requires_extra( + &catalog_json, + &read_jstring(&mut env, &extra_name)?, + )) + }) + .unwrap_or(false); + if result { + 1 + } else { + 0 + } +} + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_catalogHasRequiredExtraExceptNative( + mut env: JNIEnv<'_>, + _class: JObject<'_>, + catalog_json: JString<'_>, + allowed_names_json: JString<'_>, +) -> JBoolean { + let result = read_jstring(&mut env, &catalog_json) + .and_then(|catalog_json| { + Some(catalog_has_required_extra_except( + &catalog_json, + &read_jstring(&mut env, &allowed_names_json)?, + )) + }) + .unwrap_or(false); + if result { + 1 + } else { + 0 + } +} + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_parseEpisodeLocatorJsonNative( + mut env: JNIEnv<'_>, + _class: JObject<'_>, + input: JString<'_>, +) -> JStringReturn { + let output = read_jstring(&mut env, &input).and_then(|input| { + let (base_id, season, episode) = parse_episode_locator(&input)?; + serde_json::to_string(&json!({ + "baseId": base_id, + "season": season, + "episode": episode + })) + .ok() + }); + write_jstring(&mut env, output) +} + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_streamRequestIdsJsonNative( + mut env: JNIEnv<'_>, + _class: JObject<'_>, + content_type: JString<'_>, + id: JString<'_>, + detail_id: JString<'_>, + current_series_lookup_id: JString<'_>, + canonical_base_id: JString<'_>, +) -> JStringReturn { + let output = read_jstring(&mut env, &content_type).and_then(|content_type| { + serde_json::to_string(&stream_request_ids( + &content_type, + &read_jstring(&mut env, &id)?, + read_jstring(&mut env, &detail_id) + .filter(|value| !value.is_empty()) + .as_deref(), + read_jstring(&mut env, ¤t_series_lookup_id) + .filter(|value| !value.is_empty()) + .as_deref(), + read_jstring(&mut env, &canonical_base_id) + .filter(|value| !value.is_empty()) + .as_deref(), + )) + .ok() + }); + write_jstring(&mut env, output) +} + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_playbackStreamRequestIdsJsonNative( + mut env: JNIEnv<'_>, + _class: JObject<'_>, + content_type: JString<'_>, + id: JString<'_>, + detail_id: JString<'_>, +) -> JStringReturn { + let output = read_jstring(&mut env, &content_type).and_then(|content_type| { + playback_stream_request_ids_json( + &content_type, + &read_jstring(&mut env, &id)?, + read_jstring(&mut env, &detail_id) + .filter(|value| !value.is_empty()) + .as_deref(), + ) + }); + write_jstring(&mut env, output) +} + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_streamDiscoveryEpisodeContextJsonNative( + mut env: JNIEnv<'_>, + _class: JObject<'_>, + content_type: JString<'_>, + request_id: JString<'_>, + detail_json: JString<'_>, + season_episodes_json: JString<'_>, +) -> JStringReturn { + let output = read_jstring(&mut env, &content_type).and_then(|content_type| { + stream_discovery_episode_context_json( + &content_type, + &read_jstring(&mut env, &request_id)?, + read_jstring(&mut env, &detail_json) + .filter(|value| !value.is_empty()) + .as_deref(), + &read_jstring(&mut env, &season_episodes_json)?, + ) + }); + write_jstring(&mut env, output) +} + +string_fn!( + Java_com_fluxa_app_core_rust_FluxaCoreNative_playbackIntroLookupContentIdNative, + |value: String| playback_intro_lookup_content_id(&value) +); + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_directPlaybackPlanJsonNative( + mut env: JNIEnv<'_>, + _class: JObject<'_>, + meta_json: JString<'_>, + detail_json: JString<'_>, + today_iso: JString<'_>, +) -> JStringReturn { + let output = read_jstring(&mut env, &meta_json).and_then(|meta_json| { + direct_playback_plan_json( + &meta_json, + read_jstring(&mut env, &detail_json) + .filter(|value| !value.is_empty()) + .as_deref(), + &read_jstring(&mut env, &today_iso)?, + ) + }); + write_jstring(&mut env, output) +} + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_streamPlaybackInfoJsonNative( + mut env: JNIEnv<'_>, + _class: JObject<'_>, + stream_json: JString<'_>, +) -> JStringReturn { + let output = read_jstring(&mut env, &stream_json) + .and_then(|stream_json| stream_playback_info_json(&stream_json)); + write_jstring(&mut env, output) +} + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_streamRequestHeadersJsonNative( + mut env: JNIEnv<'_>, + _class: JObject<'_>, + headers_json: JString<'_>, +) -> JStringReturn { + let output = read_jstring(&mut env, &headers_json) + .and_then(|headers_json| stream_request_headers_json(&headers_json)); + write_jstring(&mut env, output) +} + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_streamRequestRefererNative( + mut env: JNIEnv<'_>, + _class: JObject<'_>, + url: JString<'_>, +) -> JStringReturn { + let output = + read_jstring(&mut env, &url).map(|url| stream_request_referer(&url).unwrap_or_default()); + write_jstring(&mut env, output) +} + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_episodeTextMatchesNative( + mut env: JNIEnv<'_>, + _class: JObject<'_>, + text: JString<'_>, + season: JInt, + episode: JInt, +) -> JBoolean { + let result = read_jstring(&mut env, &text) + .map(|text| text_matches_episode(&text, season, episode)) + .unwrap_or(false); + if result { + 1 + } else { + 0 + } +} + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_streamMatchesEpisodeNative( + mut env: JNIEnv<'_>, + _class: JObject<'_>, + video_id: JString<'_>, + title: JString<'_>, + name: JString<'_>, + description: JString<'_>, + filename: JString<'_>, + effective_filename: JString<'_>, +) -> JBoolean { + let result = read_jstring(&mut env, &video_id) + .map(|video_id| { + stream_matches_episode( + &video_id, + &[ + read_jstring(&mut env, &title).unwrap_or_default(), + read_jstring(&mut env, &name).unwrap_or_default(), + read_jstring(&mut env, &description).unwrap_or_default(), + read_jstring(&mut env, &filename).unwrap_or_default(), + read_jstring(&mut env, &effective_filename).unwrap_or_default(), + ], + ) + }) + .unwrap_or(false); + if result { + 1 + } else { + 0 + } +} + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_selectStreamIndexNative( + mut env: JNIEnv<'_>, + _class: JObject<'_>, + streams_json: JString<'_>, + current_video_id: JString<'_>, + initial_stream_index: JInt, + saved_url: JString<'_>, + saved_title: JString<'_>, + source_selection_mode: JString<'_>, + regex_pattern: JString<'_>, + preferred_binge_group: JString<'_>, +) -> JInt { + read_jstring(&mut env, &streams_json) + .and_then(|streams_json| { + Some(select_stream_index( + &streams_json, + &read_jstring(&mut env, ¤t_video_id).unwrap_or_default(), + initial_stream_index, + read_jstring(&mut env, &saved_url) + .filter(|value| !value.is_empty()) + .as_deref(), + read_jstring(&mut env, &saved_title) + .filter(|value| !value.is_empty()) + .as_deref(), + &read_jstring(&mut env, &source_selection_mode)?, + read_jstring(&mut env, ®ex_pattern) + .filter(|value| !value.is_empty()) + .as_deref(), + read_jstring(&mut env, &preferred_binge_group) + .filter(|value| !value.is_empty()) + .as_deref(), + )) + }) + .unwrap_or(-1) +} + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_mergeContinueWatchingDuplicatesJsonNative( + mut env: JNIEnv<'_>, + _class: JObject<'_>, + items_json: JString<'_>, +) -> JStringReturn { + let output = read_jstring(&mut env, &items_json) + .and_then(|items_json| merge_continue_watching_duplicates_json(&items_json)); + write_jstring(&mut env, output) +} + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_filterDiscoverResultsJsonNative( + mut env: JNIEnv<'_>, + _class: JObject<'_>, + items_json: JString<'_>, + year: JString<'_>, + rating: JFloat, + has_rating: JBoolean, + region: JString<'_>, +) -> JStringReturn { + let output = read_jstring(&mut env, &items_json).and_then(|items_json| { + filter_discover_results_json( + &items_json, + read_jstring(&mut env, &year) + .filter(|value| !value.trim().is_empty()) + .as_deref(), + if has_rating != 0 { Some(rating) } else { None }, + read_jstring(&mut env, ®ion) + .filter(|value| !value.trim().is_empty()) + .as_deref(), + ) + }); + write_jstring(&mut env, output) +} + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_resolvePreferredAudioLanguageNative( + mut env: JNIEnv<'_>, + _class: JObject<'_>, + last_audio_language: JString<'_>, + preferred_audio_language: JString<'_>, + original_language: JString<'_>, +) -> JStringReturn { + let output = Some(resolve_preferred_audio_language( + read_jstring(&mut env, &last_audio_language) + .filter(|value| !value.is_empty()) + .as_deref(), + read_jstring(&mut env, &preferred_audio_language) + .filter(|value| !value.is_empty()) + .as_deref(), + read_jstring(&mut env, &original_language) + .filter(|value| !value.is_empty()) + .as_deref(), + )); + write_jstring(&mut env, output) +} + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_subtitleLanguageMatchesNative( + mut env: JNIEnv<'_>, + _class: JObject<'_>, + label: JString<'_>, + language: JString<'_>, + preferred_language: JString<'_>, +) -> JBoolean { + let result = read_jstring(&mut env, &label) + .and_then(|label| { + Some(subtitle_language_matches( + &label, + read_jstring(&mut env, &language) + .filter(|value| !value.is_empty()) + .as_deref(), + &read_jstring(&mut env, &preferred_language)?, + )) + }) + .unwrap_or(false); + if result { + 1 + } else { + 0 + } +} + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_findPreferredSubtitleIndexNative( + mut env: JNIEnv<'_>, + _class: JObject<'_>, + tracks_json: JString<'_>, + last_subtitle_language: JString<'_>, + preferred_subtitle_language: JString<'_>, + secondary_subtitle_language: JString<'_>, +) -> JInt { + read_jstring(&mut env, &tracks_json) + .map(|tracks_json| { + find_preferred_subtitle_index( + &tracks_json, + read_jstring(&mut env, &last_subtitle_language) + .filter(|value| !value.is_empty()) + .as_deref(), + read_jstring(&mut env, &preferred_subtitle_language) + .filter(|value| !value.is_empty()) + .as_deref(), + read_jstring(&mut env, &secondary_subtitle_language) + .filter(|value| !value.is_empty()) + .as_deref(), + ) + }) + .unwrap_or(-1) +} + + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_parseAddonResourceResultJsonNative( + mut env: JNIEnv<'_>, + _class: JObject<'_>, + resource: JString<'_>, + url: JString<'_>, + status_code: JInt, + body: JString<'_>, +) -> JStringReturn { + let output = read_jstring(&mut env, &resource).and_then(|resource| { + Some(parse_addon_resource_result_json( + &resource, + &read_jstring(&mut env, &url)?, + status_code, + read_jstring(&mut env, &body).as_deref(), + )) + }); + write_jstring(&mut env, output) +} + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_normalizeAddonSubtitlesJsonNative( + mut env: JNIEnv<'_>, + _class: JObject<'_>, + subtitles_json: JString<'_>, + resource_url: JString<'_>, +) -> JStringReturn { + let output = read_jstring(&mut env, &subtitles_json).and_then(|subtitles_json| { + Some(normalize_addon_subtitles_json( + &subtitles_json, + &read_jstring(&mut env, &resource_url)?, + )) + }); + write_jstring(&mut env, output) +} + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_torrentRuntimeInfoJsonNative( + mut env: JNIEnv<'_>, + _class: JObject<'_>, + request_json: JString<'_>, +) -> JStringReturn { + let output = read_jstring(&mut env, &request_json) + .and_then(|request_json| torrent_runtime_info_json(&request_json)); + write_jstring(&mut env, output) +} + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_torrentStatusInfoJsonNative( + mut env: JNIEnv<'_>, + _class: JObject<'_>, + status_json: JString<'_>, +) -> JStringReturn { + let output = read_jstring(&mut env, &status_json) + .and_then(|status_json| torrent_status_info_json(&status_json)); + write_jstring(&mut env, output) +} + +string_fn!( + Java_com_fluxa_app_core_rust_FluxaCoreNative_stableFeedPartNative, + |value: String| stable_feed_part(&value) +); + +string_fn!( + Java_com_fluxa_app_core_rust_FluxaCoreNative_normalizeContentTypeNative, + |value: String| normalize_content_type(&value).unwrap_or("").to_string() +); + +string_fn!( + Java_com_fluxa_app_core_rust_FluxaCoreNative_traktBearerNative, + |value: String| trakt_bearer(&value) +); + +string_fn!( + Java_com_fluxa_app_core_rust_FluxaCoreNative_traktScrobbleUrlNative, + |value: String| trakt_scrobble_url(&value) +); + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_traktHasClientNative( + mut env: JNIEnv<'_>, + _class: JObject<'_>, + api_key: JString<'_>, +) -> JBoolean { + let result = read_jstring(&mut env, &api_key) + .map(|api_key| trakt_has_client(&api_key)) + .unwrap_or(false); + if result { + 1 + } else { + 0 + } +} + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_traktPlaybackUrlNative( + mut env: JNIEnv<'_>, + _class: JObject<'_>, + content_type: JString<'_>, +) -> JStringReturn { + let output = Some(trakt_playback_url( + read_jstring(&mut env, &content_type) + .filter(|value| !value.trim().is_empty()) + .as_deref(), + )); + write_jstring(&mut env, output) +} + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_traktTokenExpiresAtNative( + _env: JNIEnv<'_>, + _class: JObject<'_>, + created_at_seconds: JLong, + expires_in_seconds: JLong, +) -> JLong { + trakt_token_expires_at(created_at_seconds, expires_in_seconds) +} + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_traktContentIdFromIdsNative( + mut env: JNIEnv<'_>, + _class: JObject<'_>, + ids_json: JString<'_>, +) -> JStringReturn { + let output = read_jstring(&mut env, &ids_json) + .and_then(|ids_json| trakt_content_id_from_ids_json(&ids_json)); + write_jstring(&mut env, output) +} + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_traktIdsFromContentIdJsonNative( + mut env: JNIEnv<'_>, + _class: JObject<'_>, + raw_id: JString<'_>, +) -> JStringReturn { + let output = + read_jstring(&mut env, &raw_id).and_then(|raw_id| trakt_ids_from_content_id_json(&raw_id)); + write_jstring(&mut env, output) +} + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_traktEpisodeLocatorJsonNative( + mut env: JNIEnv<'_>, + _class: JObject<'_>, + video_id: JString<'_>, +) -> JStringReturn { + let output = read_jstring(&mut env, &video_id) + .and_then(|video_id| trakt_episode_locator_json(&video_id)); + write_jstring(&mut env, output) +} + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_traktShowIdFromEpisodeIdNative( + mut env: JNIEnv<'_>, + _class: JObject<'_>, + video_id: JString<'_>, +) -> JStringReturn { + let output = + read_jstring(&mut env, &video_id).map(|video_id| trakt_show_id_from_episode_id(&video_id)); + write_jstring(&mut env, output) +} + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_traktScrobbleMediaIdNative( + mut env: JNIEnv<'_>, + _class: JObject<'_>, + parent_id: JString<'_>, + video_id: JString<'_>, + media_type: JString<'_>, +) -> JStringReturn { + let output = read_jstring(&mut env, &parent_id).and_then(|parent_id| { + Some(trakt_scrobble_media_id( + &parent_id, + read_jstring(&mut env, &video_id) + .filter(|value| !value.is_empty()) + .as_deref(), + &read_jstring(&mut env, &media_type)?, + )) + }); + write_jstring(&mut env, output) +} + +string_fn!( + Java_com_fluxa_app_core_rust_FluxaCoreNative_traktOAuthErrorCodeNative, + |value: String| trakt_oauth_error_code(&value).unwrap_or_default() +); + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_traktHistoryRequestJsonNative( + mut env: JNIEnv<'_>, + _class: JObject<'_>, + meta_json: JString<'_>, + episodes_json: JString<'_>, +) -> JStringReturn { + let output = read_jstring(&mut env, &meta_json).and_then(|meta_json| { + trakt_history_request_json(&meta_json, &read_jstring(&mut env, &episodes_json)?) + }); + write_jstring(&mut env, output) +} + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_playbackProgressItemJsonNative( + mut env: JNIEnv<'_>, + _class: JObject<'_>, + meta_json: JString<'_>, + time_offset: JLong, + duration: JLong, + now_utc: JString<'_>, +) -> JStringReturn { + let output = read_jstring(&mut env, &meta_json).and_then(|meta_json| { + playback_progress_item_json( + &meta_json, + time_offset, + duration, + &read_jstring(&mut env, &now_utc)?, + ) + }); + write_jstring(&mut env, output) +} + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_clearPlaybackProgressItemJsonNative( + mut env: JNIEnv<'_>, + _class: JObject<'_>, + meta_json: JString<'_>, +) -> JStringReturn { + let output = read_jstring(&mut env, &meta_json) + .and_then(|meta_json| clear_playback_progress_item_json(&meta_json)); + write_jstring(&mut env, output) +} + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_watchedStateItemsJsonNative( + mut env: JNIEnv<'_>, + _class: JObject<'_>, + meta_json: JString<'_>, + episodes_json: JString<'_>, + watched: JBoolean, + watched_at: JString<'_>, +) -> JStringReturn { + let output = read_jstring(&mut env, &meta_json).and_then(|meta_json| { + watched_state_items_json( + &meta_json, + &read_jstring(&mut env, &episodes_json)?, + watched != 0, + read_jstring(&mut env, &watched_at) + .filter(|value| !value.is_empty()) + .as_deref(), + ) + }); + write_jstring(&mut env, output) +} + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_libraryContinueWatchingItemsJsonNative( + mut env: JNIEnv<'_>, + _class: JObject<'_>, + items_json: JString<'_>, +) -> JStringReturn { + let output = read_jstring(&mut env, &items_json) + .and_then(|items_json| library_continue_watching_items_json(&items_json)); + write_jstring(&mut env, output) +} + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_filterHomeContinueWatchingJsonNative( + mut env: JNIEnv<'_>, + _class: JObject<'_>, + items_json: JString<'_>, + trakt_watched_json: JString<'_>, +) -> JStringReturn { + let output = read_jstring(&mut env, &items_json).and_then(|items_json| { + filter_home_continue_watching_json( + &items_json, + &read_jstring(&mut env, &trakt_watched_json).unwrap_or_default(), + ) + }); + write_jstring(&mut env, output) +} + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_watchedVideoIdsJsonNative( + mut env: JNIEnv<'_>, + _class: JObject<'_>, + items_json: JString<'_>, + imdb_id: JString<'_>, +) -> JStringReturn { + let output = read_jstring(&mut env, &items_json).and_then(|items_json| { + watched_video_ids_json(&items_json, &read_jstring(&mut env, &imdb_id)?) + }); + write_jstring(&mut env, output) +} + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_parseExtraArgsJsonNative( + mut env: JNIEnv<'_>, + _class: JObject<'_>, + extra: JString<'_>, +) -> JStringReturn { + let output = read_jstring(&mut env, &extra).and_then(|extra| parse_extra_args_json(&extra)); + write_jstring(&mut env, output) +} + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_providerSearchTermsJsonNative( + mut env: JNIEnv<'_>, + _class: JObject<'_>, + provider: JString<'_>, +) -> JStringReturn { + let output = read_jstring(&mut env, &provider) + .and_then(|provider| serde_json::to_string(&provider_search_terms(&provider)).ok()); + write_jstring(&mut env, output) +} + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_effectiveMetadataFeedSelectionJsonNative( + mut env: JNIEnv<'_>, + _class: JObject<'_>, + selected_keys_json: JString<'_>, + available_keys_json: JString<'_>, +) -> JStringReturn { + let output = read_jstring(&mut env, &selected_keys_json).and_then(|selected_keys_json| { + effective_metadata_feed_selection_json( + &selected_keys_json, + &read_jstring(&mut env, &available_keys_json)?, + ) + }); + write_jstring(&mut env, output) +} + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_toggleMetadataFeedJsonNative( + mut env: JNIEnv<'_>, + _class: JObject<'_>, + selected_keys_json: JString<'_>, + available_keys_json: JString<'_>, + key: JString<'_>, +) -> JStringReturn { + let output = read_jstring(&mut env, &selected_keys_json).and_then(|selected_keys_json| { + toggle_metadata_feed_json( + &selected_keys_json, + &read_jstring(&mut env, &available_keys_json)?, + &read_jstring(&mut env, &key)?, + ) + }); + write_jstring(&mut env, output) +} + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_toggleMetadataFeedLimitedJsonNative( + mut env: JNIEnv<'_>, + _class: JObject<'_>, + selected_keys_json: JString<'_>, + available_keys_json: JString<'_>, + key: JString<'_>, + max_enabled: JInt, +) -> JStringReturn { + let output = read_jstring(&mut env, &selected_keys_json).and_then(|selected_keys_json| { + toggle_metadata_feed_limited_json( + &selected_keys_json, + &read_jstring(&mut env, &available_keys_json)?, + &read_jstring(&mut env, &key)?, + max_enabled, + ) + }); + write_jstring(&mut env, output) +} + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_setMetadataFeedGroupEnabledJsonNative( + mut env: JNIEnv<'_>, + _class: JObject<'_>, + selected_keys_json: JString<'_>, + available_keys_json: JString<'_>, + group_keys_json: JString<'_>, + enabled: JBoolean, +) -> JStringReturn { + let output = read_jstring(&mut env, &selected_keys_json).and_then(|selected_keys_json| { + set_metadata_feed_group_enabled_json( + &selected_keys_json, + &read_jstring(&mut env, &available_keys_json)?, + &read_jstring(&mut env, &group_keys_json)?, + enabled != 0, + ) + }); + write_jstring(&mut env, output) +} + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_orderedMetadataFeedKeysJsonNative( + mut env: JNIEnv<'_>, + _class: JObject<'_>, + option_keys_json: JString<'_>, + order_json: JString<'_>, +) -> JStringReturn { + let output = read_jstring(&mut env, &option_keys_json).and_then(|option_keys_json| { + ordered_metadata_feed_keys(&option_keys_json, &read_jstring(&mut env, &order_json)?) + }); + write_jstring(&mut env, output) +} + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_moveMetadataFeedOrderJsonNative( + mut env: JNIEnv<'_>, + _class: JObject<'_>, + option_keys_json: JString<'_>, + current_order_json: JString<'_>, + key: JString<'_>, + delta: JInt, +) -> JStringReturn { + let output = read_jstring(&mut env, &option_keys_json).and_then(|option_keys_json| { + move_metadata_feed_order_json( + &option_keys_json, + &read_jstring(&mut env, ¤t_order_json)?, + &read_jstring(&mut env, &key)?, + delta, + ) + }); + write_jstring(&mut env, output) +} + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_contentTraktKeyNative( + mut env: JNIEnv<'_>, + _class: JObject<'_>, + meta_json: JString<'_>, +) -> JStringReturn { + let output = + read_jstring(&mut env, &meta_json).and_then(|meta_json| content_trakt_key(&meta_json)); + write_jstring(&mut env, output) +} + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_contentBillboardKeyNative( + mut env: JNIEnv<'_>, + _class: JObject<'_>, + meta_json: JString<'_>, +) -> JStringReturn { + let output = + read_jstring(&mut env, &meta_json).and_then(|meta_json| content_billboard_key(&meta_json)); + write_jstring(&mut env, output) +} + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_contentMergeKeysJsonNative( + mut env: JNIEnv<'_>, + _class: JObject<'_>, + meta_json: JString<'_>, +) -> JStringReturn { + let output = read_jstring(&mut env, &meta_json) + .and_then(|meta_json| content_keys_json(&meta_json, false)); + write_jstring(&mut env, output) +} + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_contentWatchedKeysJsonNative( + mut env: JNIEnv<'_>, + _class: JObject<'_>, + meta_json: JString<'_>, +) -> JStringReturn { + let output = read_jstring(&mut env, &meta_json) + .and_then(|meta_json| content_keys_json(&meta_json, true)); + write_jstring(&mut env, output) +} + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_episodeFilenameCandidateNative( + mut env: JNIEnv<'_>, + _class: JObject<'_>, + stream_json: JString<'_>, + video_id: JString<'_>, +) -> JStringReturn { + let output = read_jstring(&mut env, &stream_json).and_then(|stream_json| { + episode_filename_candidate(&stream_json, &read_jstring(&mut env, &video_id)?) + }); + write_jstring(&mut env, output) +} + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_streamDiscoveryCacheKeyNative( + mut env: JNIEnv<'_>, + _class: JObject<'_>, + request_json: JString<'_>, +) -> JStringReturn { + let output = read_jstring(&mut env, &request_json) + .and_then(|request_json| stream_discovery_cache_key(&request_json)); + write_jstring(&mut env, output) +} + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_discoverCatalogCacheKeyNative( + mut env: JNIEnv<'_>, + _class: JObject<'_>, + request_json: JString<'_>, +) -> JStringReturn { + let output = read_jstring(&mut env, &request_json) + .and_then(|request_json| discover_catalog_cache_key(&request_json)); + write_jstring(&mut env, output) +} + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_streamDiscoveryPlanJsonNative( + mut env: JNIEnv<'_>, + _class: JObject<'_>, + request_json: JString<'_>, +) -> JStringReturn { + let output = read_jstring(&mut env, &request_json) + .and_then(|request_json| stream_discovery_plan_json(&request_json)); + write_jstring(&mut env, output) +} + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_streamDiscoveryExecutionPolicyJsonNative( + mut env: JNIEnv<'_>, + _class: JObject<'_>, + request_json: JString<'_>, +) -> JStringReturn { + let output = read_jstring(&mut env, &request_json) + .and_then(|request_json| stream_discovery_execution_policy_json(&request_json)); + write_jstring(&mut env, output) +} + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_streamDiscoveryCachePrefixNative( + mut env: JNIEnv<'_>, + _class: JObject<'_>, + content_type: JString<'_>, + id: JString<'_>, + language: JString<'_>, +) -> JStringReturn { + let output = read_jstring(&mut env, &content_type).and_then(|content_type| { + Some(stream_discovery_cache_prefix( + &content_type, + &read_jstring(&mut env, &id)?, + &read_jstring(&mut env, &language)?, + )) + }); + write_jstring(&mut env, output) +} + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_curateHomeItemsJsonNative( + mut env: JNIEnv<'_>, + _class: JObject<'_>, + category_json: JString<'_>, +) -> JStringReturn { + let output = read_jstring(&mut env, &category_json) + .and_then(|category_json| curate_home_items_json(&category_json)); + write_jstring(&mut env, output) +} + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_homeOverlapRatioNative( + mut env: JNIEnv<'_>, + _class: JObject<'_>, + first_json: JString<'_>, + second_json: JString<'_>, +) -> JFloat { + read_jstring(&mut env, &first_json) + .and_then(|first_json| { + home_overlap_ratio_json(&first_json, &read_jstring(&mut env, &second_json)?) + }) + .unwrap_or(0.0) +} + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_homePersonalizationScoreNative( + mut env: JNIEnv<'_>, + _class: JObject<'_>, + category_json: JString<'_>, + preferred_genres_json: JString<'_>, + preferred_types_json: JString<'_>, + priority_labels_json: JString<'_>, +) -> JInt { + read_jstring(&mut env, &category_json) + .and_then(|category_json| { + home_personalization_score_json( + &category_json, + &read_jstring(&mut env, &preferred_genres_json)?, + &read_jstring(&mut env, &preferred_types_json)?, + &read_jstring(&mut env, &priority_labels_json)?, + ) + }) + .unwrap_or(0) +} + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_prioritizeHomeRowsJsonNative( + mut env: JNIEnv<'_>, + _class: JObject<'_>, + categories_json: JString<'_>, + preferred_order_labels_json: JString<'_>, + preferred_genres_json: JString<'_>, + preferred_types_json: JString<'_>, + priority_labels_json: JString<'_>, +) -> JStringReturn { + let output = read_jstring(&mut env, &categories_json).and_then(|categories_json| { + home_prioritize_rows_json( + &categories_json, + &read_jstring(&mut env, &preferred_order_labels_json)?, + &read_jstring(&mut env, &preferred_genres_json)?, + &read_jstring(&mut env, &preferred_types_json)?, + &read_jstring(&mut env, &priority_labels_json)?, + ) + }); + write_jstring(&mut env, output) +} + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_optimizeHomeRowsJsonNative( + mut env: JNIEnv<'_>, + _class: JObject<'_>, + request_json: JString<'_>, +) -> JStringReturn { + let output = read_jstring(&mut env, &request_json) + .and_then(|request_json| optimize_home_rows_json(&request_json)); + write_jstring(&mut env, output) +} + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_billboardScoreCandidateNative( + mut env: JNIEnv<'_>, + _class: JObject<'_>, + meta_json: JString<'_>, + days_since_release: JLong, + has_days_since_release: JBoolean, +) -> JInt { + read_jstring(&mut env, &meta_json) + .and_then(|meta_json| { + billboard_score_candidate_json( + &meta_json, + if has_days_since_release != 0 { + Some(days_since_release) + } else { + None + }, + ) + }) + .unwrap_or(0) +} + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_billboardHasBackdropCandidateNative( + mut env: JNIEnv<'_>, + _class: JObject<'_>, + meta_json: JString<'_>, +) -> JBoolean { + let result = read_jstring(&mut env, &meta_json) + .map(|meta_json| has_billboard_backdrop_candidate_json(&meta_json)) + .unwrap_or(false); + if result { + 1 + } else { + 0 + } +} + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_billboardVisualScoreNative( + mut env: JNIEnv<'_>, + _class: JObject<'_>, + meta_json: JString<'_>, +) -> JInt { + read_jstring(&mut env, &meta_json) + .and_then(|meta_json| billboard_visual_score_json(&meta_json)) + .unwrap_or(0) +} + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_billboardEditorialMatchScoreNative( + mut env: JNIEnv<'_>, + _class: JObject<'_>, + meta_json: JString<'_>, + spec_json: JString<'_>, +) -> JInt { + read_jstring(&mut env, &meta_json) + .and_then(|meta_json| { + billboard_editorial_match_score_json(&meta_json, &read_jstring(&mut env, &spec_json)?) + }) + .unwrap_or(0) +} + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_playerProgressPercentNative( + _env: JNIEnv<'_>, + _class: JObject<'_>, + position_ms: JLong, + duration_ms: JLong, +) -> f32 { + progress_percent(position_ms, duration_ms) +} + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_playerShouldSendScrobbleStartNative( + mut env: JNIEnv<'_>, + _class: JObject<'_>, + token: JString<'_>, + is_playing: JBoolean, + has_scrobbled_start: JBoolean, + progress: f32, +) -> JBoolean { + let token = read_jstring(&mut env, &token); + if should_send_start( + token.as_deref(), + is_playing != 0, + has_scrobbled_start != 0, + progress, + ) { + 1 + } else { + 0 + } +} + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_playerShouldMarkScrobbleStoppedNative( + _env: JNIEnv<'_>, + _class: JObject<'_>, + has_scrobbled_stop: JBoolean, + progress: f32, +) -> JBoolean { + if should_mark_stopped(has_scrobbled_stop != 0, progress) { + 1 + } else { + 0 + } +} + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_playerShouldQueueScrobblePauseNative( + mut env: JNIEnv<'_>, + _class: JObject<'_>, + token: JString<'_>, + was_play_when_ready: JBoolean, + has_scrobbled_start: JBoolean, + has_scrobbled_stop: JBoolean, +) -> JBoolean { + let token = read_jstring(&mut env, &token); + if should_queue_pause( + token.as_deref(), + was_play_when_ready != 0, + has_scrobbled_start != 0, + has_scrobbled_stop != 0, + ) { + 1 + } else { + 0 + } +} + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_playerShouldEnqueueDurableScrobbleNative( + mut env: JNIEnv<'_>, + _class: JObject<'_>, + action: JString<'_>, + token: JString<'_>, + progress: f32, +) -> JBoolean { + let action = read_jstring(&mut env, &action).unwrap_or_default(); + let token = read_jstring(&mut env, &token); + if should_enqueue_durable(&action, token.as_deref(), progress) { + 1 + } else { + 0 + } +} + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_playerShouldSavePeriodicProgressNative( + _env: JNIEnv<'_>, + _class: JObject<'_>, + is_playing: JBoolean, + now_ms: JLong, + last_saved_at_ms: JLong, +) -> JBoolean { + if should_save_periodic_progress(is_playing != 0, now_ms, last_saved_at_ms) { + 1 + } else { + 0 + } +} + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_playerShouldSaveOnDisposeNative( + _env: JNIEnv<'_>, + _class: JObject<'_>, + position_ms: JLong, +) -> JBoolean { + if should_save_on_dispose(position_ms) { + 1 + } else { + 0 + } +} + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_safePlayerBufferCacheMbNative( + _env: JNIEnv<'_>, + _class: JObject<'_>, + value: JInt, + has_value: JBoolean, +) -> JInt { + safe_player_buffer_cache_mb(if has_value != 0 { Some(value) } else { None }) +} + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_safeDolbyVisionFallbackModeNative( + mut env: JNIEnv<'_>, + _class: JObject<'_>, + mode: JString<'_>, + legacy_dv7_fallback: JBoolean, + has_legacy_dv7_fallback: JBoolean, + legacy_dv7_to_dv8_fallback: JBoolean, + has_legacy_dv7_to_dv8_fallback: JBoolean, +) -> JStringReturn { + let mode = read_jstring(&mut env, &mode); + let output = safe_dolby_vision_fallback_mode( + mode.as_deref().filter(|value| !value.is_empty()), + if has_legacy_dv7_fallback != 0 { + Some(legacy_dv7_fallback != 0) + } else { + None + }, + if has_legacy_dv7_to_dv8_fallback != 0 { + Some(legacy_dv7_to_dv8_fallback != 0) + } else { + None + }, + ) + .to_string(); + write_jstring(&mut env, Some(output)) +} + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_safeStreamSourceSelectionModeNative( + mut env: JNIEnv<'_>, + _class: JObject<'_>, + mode: JString<'_>, +) -> JStringReturn { + let mode = read_jstring(&mut env, &mode); + let output = + safe_stream_source_selection_mode(mode.as_deref().filter(|value| !value.is_empty())) + .to_string(); + write_jstring(&mut env, Some(output)) +} + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_playerFlowDispatchJsonNative( + mut env: JNIEnv<'_>, + _class: JObject<'_>, + state_json: JString<'_>, + action_json: JString<'_>, +) -> JStringReturn { + let output = read_jstring(&mut env, &state_json).and_then(|state_json| { + player_flow_dispatch_json(&state_json, &read_jstring(&mut env, &action_json)?) + }); + write_jstring(&mut env, output) +} + +// ── calendar_plan ───────────────────────────────────────────────────────────── +string_fn!( + Java_com_fluxa_app_core_rust_FluxaCoreNative_calendarContentPlanJsonNative, + |value: String| calendar_content_plan_json(&value).unwrap_or_default() +); +string_fn!( + Java_com_fluxa_app_core_rust_FluxaCoreNative_calendarSeasonCandidatesJsonNative, + |value: String| calendar_season_candidates_json(&value).unwrap_or_default() +); +string_fn!( + Java_com_fluxa_app_core_rust_FluxaCoreNative_calendarWidgetRowsJsonNative, + |value: String| calendar_widget_rows_json(&value).unwrap_or_default() +); +string_fn!( + Java_com_fluxa_app_core_rust_FluxaCoreNative_calendarNotificationContentJsonNative, + |value: String| calendar_notification_content_json(&value).unwrap_or_default() +); +string_fn!( + Java_com_fluxa_app_core_rust_FluxaCoreNative_calendarReleaseDetectionJsonNative, + |value: String| calendar_release_detection_json(&value).unwrap_or_default() +); + +// ── profile_contract ────────────────────────────────────────────────────────── +string_fn!( + Java_com_fluxa_app_core_rust_FluxaCoreNative_activeProfilePlanJsonNative, + |value: String| active_profile_plan_json(&value).unwrap_or_default() +); +string_fn!( + Java_com_fluxa_app_core_rust_FluxaCoreNative_tokenMergePlanJsonNative, + |value: String| token_merge_plan_json(&value).unwrap_or_default() +); +string_fn!( + Java_com_fluxa_app_core_rust_FluxaCoreNative_profileDefaultSeedJsonNative, + |value: String| profile_default_seed_json(&value).unwrap_or_default() +); +string_fn!( + Java_com_fluxa_app_core_rust_FluxaCoreNative_profileSettingsMigrationPlanJsonNative, + |value: String| profile_settings_migration_plan_json(&value).unwrap_or_default() +); +string_fn!( + Java_com_fluxa_app_core_rust_FluxaCoreNative_profileAvatarDefaultJsonNative, + |value: String| profile_avatar_default_json(&value).unwrap_or_default() +); + +// ── watchlist_plan ──────────────────────────────────────────────────────────── +string_fn!( + Java_com_fluxa_app_core_rust_FluxaCoreNative_watchlistTogglePlanJsonNative, + |value: String| watchlist_toggle_plan_json(&value).unwrap_or_default() +); +string_fn!( + Java_com_fluxa_app_core_rust_FluxaCoreNative_libraryExternalMergePlanJsonNative, + |value: String| library_external_merge_plan_json(&value).unwrap_or_default() +); +string_fn!( + Java_com_fluxa_app_core_rust_FluxaCoreNative_libraryCollectionImportValidationJsonNative, + |value: String| library_collection_import_validation_json(&value).unwrap_or_default() +); +string_fn!( + Java_com_fluxa_app_core_rust_FluxaCoreNative_libraryOfflineGroupingJsonNative, + |value: String| library_offline_grouping_json(&value).unwrap_or_default() +); +string_fn!( + Java_com_fluxa_app_core_rust_FluxaCoreNative_playbackProgressMergePlanJsonNative, + |value: String| playback_progress_merge_plan_json(&value).unwrap_or_default() +); + +// ── player_policy ───────────────────────────────────────────────────────────── +string_fn!( + Java_com_fluxa_app_core_rust_FluxaCoreNative_playerBackendSelectionJsonNative, + |value: String| player_backend_selection_json(&value).unwrap_or_default() +); +string_fn!( + Java_com_fluxa_app_core_rust_FluxaCoreNative_dvProxyPlanJsonNative, + |value: String| dv_proxy_plan_json(&value).unwrap_or_default() +); +string_fn!( + Java_com_fluxa_app_core_rust_FluxaCoreNative_torrentFallbackFilePolicyJsonNative, + |value: String| torrent_fallback_file_policy_json(&value).unwrap_or_default() +); +string_fn!( + Java_com_fluxa_app_core_rust_FluxaCoreNative_playerBufferTargetsJsonNative, + |value: String| player_buffer_targets_json(&value).unwrap_or_default() +); +string_fn!( + Java_com_fluxa_app_core_rust_FluxaCoreNative_playerRetryPolicyJsonNative, + |value: String| player_retry_policy_json(&value).unwrap_or_default() +); +string_fn!( + Java_com_fluxa_app_core_rust_FluxaCoreNative_playerSourceSidebarPlanJsonNative, + |value: String| player_source_sidebar_plan_json(&value).unwrap_or_default() +); + +// ── search_plan ─────────────────────────────────────────────────────────────── +string_fn!( + Java_com_fluxa_app_core_rust_FluxaCoreNative_searchResultGroupingJsonNative, + |value: String| search_result_grouping_json(&value).unwrap_or_default() +); +string_fn!( + Java_com_fluxa_app_core_rust_FluxaCoreNative_discoverSortPlanJsonNative, + |value: String| discover_sort_plan_json(&value).unwrap_or_default() +); +string_fn!( + Java_com_fluxa_app_core_rust_FluxaCoreNative_librarySortPlanJsonNative, + |value: String| library_sort_plan_json(&value).unwrap_or_default() +); +string_fn!( + Java_com_fluxa_app_core_rust_FluxaCoreNative_detailSeriesLookupIdNative, + |value: String| detail_series_lookup_id(&value) +); +string_fn!( + Java_com_fluxa_app_core_rust_FluxaCoreNative_detailSeasonLoadPlanJsonNative, + |value: String| detail_season_load_plan_json(&value).unwrap_or_default() +); + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_buildBillboardPoolJsonNative( + mut env: JNIEnv<'_>, + _class: JObject<'_>, + enriched_json: JString<'_>, + candidates_json: JString<'_>, +) -> JStringReturn { + let output = read_jstring(&mut env, &enriched_json).and_then(|enriched_json| { + build_billboard_pool_json(&enriched_json, &read_jstring(&mut env, &candidates_json)?) + }); + write_jstring(&mut env, output) +} + +#[no_mangle] +pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_normalizeHomeCatalogItemsJsonNative( + mut env: JNIEnv<'_>, + _class: JObject<'_>, + items_json: JString<'_>, + catalog_id: JString<'_>, + genre: JString<'_>, + today_iso: JString<'_>, +) -> JStringReturn { + let output = read_jstring(&mut env, &items_json).and_then(|items_json| { + normalize_home_catalog_items_json( + &items_json, + &read_jstring(&mut env, &catalog_id)?, + read_jstring(&mut env, &genre) + .filter(|v| !v.is_empty()) + .as_deref(), + &read_jstring(&mut env, &today_iso)?, + ) + }); + write_jstring(&mut env, output) +} diff --git a/src/bindings/mod.rs b/src/bindings/mod.rs new file mode 100644 index 0000000..e539d32 --- /dev/null +++ b/src/bindings/mod.rs @@ -0,0 +1,3 @@ +#[cfg(feature = "native")] +pub mod jni; +pub mod uniffi; diff --git a/src/bindings/uniffi.rs b/src/bindings/uniffi.rs new file mode 100644 index 0000000..7c7239f --- /dev/null +++ b/src/bindings/uniffi.rs @@ -0,0 +1,72 @@ +use crate::{app_state, core_contract, headless_engine}; + +#[uniffi::export] +pub fn fluxa_core_version() -> String { + env!("CARGO_PKG_VERSION").to_string() +} + +#[uniffi::export] +pub fn create_headless_engine_json(initial_json: String) -> i64 { + headless_engine::create_headless_engine(&initial_json) as i64 +} + +#[uniffi::export] +pub fn destroy_headless_engine_json(handle: i64) -> bool { + handle > 0 && headless_engine::destroy_headless_engine(handle as u64) +} + +#[uniffi::export] +pub fn headless_engine_snapshot_json(handle: i64) -> String { + if handle <= 0 { + return String::new(); + } + headless_engine::headless_engine_snapshot_json(handle as u64).unwrap_or_default() +} + +#[uniffi::export] +pub fn headless_engine_dispatch_json(handle: i64, action_json: String) -> String { + if handle <= 0 { + return String::new(); + } + headless_engine::headless_engine_dispatch_json(handle as u64, &action_json).unwrap_or_default() +} + +#[uniffi::export] +pub fn headless_engine_complete_effect_json(handle: i64, result_json: String) -> String { + if handle <= 0 { + return String::new(); + } + headless_engine::headless_engine_complete_effect_json(handle as u64, &result_json) + .unwrap_or_default() +} + +#[uniffi::export] +pub fn core_capabilities_json(portable: bool) -> String { + core_contract::core_capabilities_json(portable) +} + +#[uniffi::export] +pub fn create_app_core_state_json(initial_json: String) -> i64 { + app_state::create_app_core_state(&initial_json) as i64 +} + +#[uniffi::export] +pub fn destroy_app_core_state_json(handle: i64) -> bool { + handle > 0 && app_state::destroy_app_core_state(handle as u64) +} + +#[uniffi::export] +pub fn app_core_state_json(handle: i64) -> String { + if handle <= 0 { + return String::new(); + } + app_state::app_core_state_json(handle as u64).unwrap_or_default() +} + +#[uniffi::export] +pub fn app_core_dispatch_json(handle: i64, action_json: String) -> String { + if handle <= 0 { + return String::new(); + } + app_state::app_core_dispatch_json(handle as u64, &action_json).unwrap_or_default() +} diff --git a/src/calendar_plan.rs b/src/calendar_plan.rs new file mode 100644 index 0000000..1b0482b --- /dev/null +++ b/src/calendar_plan.rs @@ -0,0 +1,399 @@ +use serde::Deserialize; +use serde_json::{json, Value}; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct CalendarItemInput { + date_iso: String, + #[serde(default)] + meta_id: String, + #[serde(default)] + meta_type: String, + #[serde(default)] + title: String, + #[serde(default)] + subtitle: Option, + #[serde(default)] + season_number: Option, + #[serde(default)] + episode_number: Option, + #[serde(default)] + episode_title: Option, + #[serde(default)] + artwork_url: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ContentPlanRequest { + #[serde(default)] + items: Vec, + month_prefix: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SeasonCandidatesRequest { + #[serde(default)] + seasons_count: Option, + #[serde(default)] + last_video_id: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct WidgetRowsRequest { + #[serde(default)] + items: Vec, + #[serde(default = "default_max_rows")] + max_rows: usize, +} + +fn default_max_rows() -> usize { + 4 +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct NotificationContentRequest { + #[serde(default)] + items: Vec, + today_iso: String, + #[serde(default)] + already_notified_keys: Vec, + #[serde(default)] + profile_id: Option, + notifications_enabled: Option, + alert_new_episodes: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ReleaseDetectionRequest { + #[serde(default)] + items: Vec, + today_iso: String, +} + +pub(crate) fn calendar_content_plan_json(request_json: &str) -> Option { + let request = serde_json::from_str::(request_json).ok()?; + let prefix = request.month_prefix.trim(); + if prefix.is_empty() { + return serde_json::to_string(&json!([])).ok(); + } + let mut seen = std::collections::HashSet::new(); + let mut filtered: Vec<&CalendarItemInput> = request + .items + .iter() + .filter(|item| { + item.date_iso.starts_with(prefix) + && !item.meta_id.trim().is_empty() + && seen.insert(format!( + "{}:{}:{}", + item.date_iso, + item.meta_id, + item.subtitle.as_deref().unwrap_or("") + )) + }) + .collect(); + filtered.sort_by(|a, b| { + a.date_iso + .cmp(&b.date_iso) + .then_with(|| a.title.cmp(&b.title)) + }); + let out: Vec = filtered + .iter() + .map(|item| { + json!({ + "dateIso": item.date_iso, + "metaId": item.meta_id, + "metaType": item.meta_type, + "title": item.title, + "subtitle": item.subtitle, + "seasonNumber": item.season_number, + "episodeNumber": item.episode_number, + "episodeTitle": item.episode_title, + "artworkUrl": item.artwork_url + }) + }) + .collect(); + serde_json::to_string(&out).ok() +} + +pub(crate) fn calendar_season_candidates_json(request_json: &str) -> Option { + let request = serde_json::from_str::(request_json).ok()?; + let seasons_count = request.seasons_count.unwrap_or(1).max(1); + let watched_season = request + .last_video_id + .as_deref() + .and_then(|id| id.split(':').nth(1)) + .and_then(|s| s.parse::().ok()); + let focused: Vec = [ + watched_season, + watched_season.map(|s| s + 1), + Some(seasons_count), + ] + .into_iter() + .flatten() + .filter(|&s| s > 0 && s <= seasons_count) + .collect::>() + .into_iter() + .collect(); + let full: Vec = if seasons_count <= 8 { + (1..=seasons_count).collect() + } else { + focused.clone() + }; + let mut result: Vec = focused + .into_iter() + .chain(full.into_iter()) + .collect::>() + .into_iter() + .collect(); + result.truncate(12); + serde_json::to_string(&result).ok() +} + +pub(crate) fn calendar_widget_rows_json(request_json: &str) -> Option { + let request = serde_json::from_str::(request_json).ok()?; + let rows: Vec = request + .items + .iter() + .take(request.max_rows) + .map(|item| { + let date_iso = item.get("dateIso").and_then(Value::as_str).unwrap_or(""); + let title = item.get("title").and_then(Value::as_str).unwrap_or(""); + let subtitle = item + .get("episodeTitle") + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) + .or_else(|| item.get("subtitle").and_then(Value::as_str)) + .unwrap_or(""); + let season = item.get("seasonNumber").and_then(Value::as_i64); + let episode = item.get("episodeNumber").and_then(Value::as_i64); + let episode_text = match (season, episode) { + (Some(s), Some(e)) => format!("S{}E{}", s, e), + (Some(s), None) => format!("S{}", s), + (None, Some(e)) => format!("E{}", e), + _ => String::new(), + }; + json!({ + "dateIso": date_iso, + "title": title, + "subtitle": subtitle, + "episodeText": episode_text + }) + }) + .collect(); + serde_json::to_string(&rows).ok() +} + +pub(crate) fn calendar_notification_content_json(request_json: &str) -> Option { + let request = serde_json::from_str::(request_json).ok()?; + if request.notifications_enabled == Some(false) || request.alert_new_episodes == Some(false) { + return serde_json::to_string(&json!({"items": [], "keys": []})).ok(); + } + let profile_id = request.profile_id.as_deref().unwrap_or(""); + let mut items_out = Vec::new(); + let mut keys_out = Vec::new(); + for item in &request.items { + if item.date_iso != request.today_iso || item.meta_type != "series" { + continue; + } + let key = format!( + "{}:{}:{}:{}", + profile_id, + item.date_iso, + item.meta_id, + item.subtitle.as_deref().unwrap_or("") + ); + if request.already_notified_keys.contains(&key) { + continue; + } + let title_key = if item.episode_number == Some(1) { + "notification.new_season_released" + } else { + "notification.new_episode_released" + }; + let body_text = match (item.season_number, item.episode_number) { + (Some(s), Some(e)) => format!("{}:season:{}:episode:{}", item.title, s, e), + _ => { + [Some(item.title.as_str()), item.subtitle.as_deref()] + .into_iter() + .flatten() + .filter(|s| !s.is_empty()) + .collect::>() + .join(" - ") + } + }; + items_out.push(json!({ + "key": key, + "titleKey": title_key, + "bodyText": body_text, + "metaId": item.meta_id, + "dateIso": item.date_iso, + "artworkUrl": item.artwork_url, + "seasonNumber": item.season_number, + "episodeNumber": item.episode_number + })); + keys_out.push(key); + } + serde_json::to_string(&json!({"items": items_out, "keys": keys_out})).ok() +} + +pub(crate) fn calendar_release_detection_json(request_json: &str) -> Option { + let request = serde_json::from_str::(request_json).ok()?; + let today = request.today_iso.trim(); + let released: Vec<&Value> = request + .items + .iter() + .filter(|item| { + item.get("dateIso") + .and_then(Value::as_str) + .is_some_and(|d| d == today) + }) + .collect(); + serde_json::to_string(&released).ok() +} + +pub(crate) fn calendar_items_from_meta_json(meta_json: &str, month_prefix: &str) -> Option { + let meta: Value = serde_json::from_str(meta_json).ok()?; + let meta_id = meta.get("id").and_then(Value::as_str).unwrap_or(""); + let meta_name = meta.get("name").and_then(Value::as_str).unwrap_or(""); + let meta_poster = meta.get("poster").and_then(Value::as_str) + .or_else(|| meta.get("background").and_then(Value::as_str)); + let videos = meta.get("videos").and_then(Value::as_array)?; + let mut items: Vec = Vec::new(); + for video in videos { + let released = video.get("released").and_then(Value::as_str).unwrap_or(""); + let date_iso = if released.len() >= 10 { &released[..10] } else { continue }; + if !month_prefix.is_empty() && !date_iso.starts_with(month_prefix) { continue; } + let season = video.get("season").and_then(Value::as_i64); + let episode = video.get("episode").or_else(|| video.get("number")).and_then(Value::as_i64); + let episode_code = match (season, episode) { + (Some(s), Some(e)) => Some(format!("S{s}:E{e}")), + _ => None, + }; + let video_name = video.get("name").or_else(|| video.get("title")).and_then(Value::as_str); + let subtitle = [episode_code.as_deref(), video_name].into_iter().flatten().collect::>().join(" "); + let poster = video.get("thumbnail").and_then(Value::as_str).or(meta_poster); + let video_id = video.get("id").and_then(Value::as_str).unwrap_or(""); + let key = format!("{meta_id}:{video_id}:{date_iso}"); + items.push(json!({ + "id": key, + "title": meta_name, + "name": video_name.unwrap_or(meta_name), + "subtitle": subtitle, + "dateIso": date_iso, + "poster": poster, + })); + } + serde_json::to_string(&items).ok() +} + +pub(crate) fn calendar_item_matches_month_json(item_json: &str, month_prefix: &str) -> bool { + if month_prefix.is_empty() { return true; } + serde_json::from_str::(item_json).ok() + .and_then(|v| v.get("dateIso").and_then(Value::as_str).map(|d| d.starts_with(month_prefix))) + .unwrap_or(false) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::Value; + + #[test] + fn content_plan_filters_deduplicates_and_sorts_by_date_then_title() { + let result: Value = serde_json::from_str( + &calendar_content_plan_json( + r#"{"monthPrefix":"2026-06","items":[ + {"dateIso":"2026-06-15","metaId":"tt1","metaType":"series","title":"B","subtitle":"E2"}, + {"dateIso":"2026-06-10","metaId":"tt2","metaType":"movie","title":"A"}, + {"dateIso":"2026-06-15","metaId":"tt1","metaType":"series","title":"B","subtitle":"E2"}, + {"dateIso":"2026-05-01","metaId":"tt3","metaType":"movie","title":"Old"} + ]}"#, + ) + .unwrap(), + ) + .unwrap(); + let arr = result.as_array().unwrap(); + assert_eq!(arr.len(), 2); + assert_eq!(arr[0]["metaId"], "tt2"); + assert_eq!(arr[1]["metaId"], "tt1"); + } + + #[test] + fn season_candidates_covers_watched_next_and_last_season() { + let result: Value = serde_json::from_str( + &calendar_season_candidates_json( + r#"{"seasonsCount":5,"lastVideoId":"tt1:2:3"}"#, + ) + .unwrap(), + ) + .unwrap(); + let seasons: Vec = result + .as_array() + .unwrap() + .iter() + .map(|v| v.as_i64().unwrap()) + .collect(); + assert!(seasons.contains(&2)); + assert!(seasons.contains(&3)); + assert!(seasons.contains(&5)); + } + + #[test] + fn widget_rows_truncates_to_max_rows() { + let items = (0..6) + .map(|i| { + json!({ + "dateIso": format!("2026-06-{:02}", i + 1), + "title": format!("Show {}", i), + "subtitle": "", + "seasonNumber": 1, + "episodeNumber": i + 1 + }) + }) + .collect::>(); + let request = json!({"items": items, "maxRows": 4}); + let result: Value = + serde_json::from_str(&calendar_widget_rows_json(&request.to_string()).unwrap()) + .unwrap(); + assert_eq!(result.as_array().unwrap().len(), 4); + } + + #[test] + fn notification_content_skips_already_notified_and_non_today_items() { + let request = json!({ + "items": [ + {"dateIso":"2026-06-10","metaId":"tt1","metaType":"series","title":"Show","subtitle":"E1","seasonNumber":1,"episodeNumber":1}, + {"dateIso":"2026-06-11","metaId":"tt2","metaType":"series","title":"Show2","subtitle":"E1","seasonNumber":1,"episodeNumber":1} + ], + "todayIso": "2026-06-10", + "alreadyNotifiedKeys": [":2026-06-10:tt1:E1"], + "notificationsEnabled": true, + "alertNewEpisodes": true + }); + let result: Value = + serde_json::from_str(&calendar_notification_content_json(&request.to_string()).unwrap()) + .unwrap(); + assert_eq!(result["items"].as_array().unwrap().len(), 0); + } + + #[test] + fn release_detection_returns_only_today_items() { + let request = json!({ + "todayIso": "2026-06-10", + "items": [ + {"dateIso":"2026-06-10","metaId":"tt1"}, + {"dateIso":"2026-06-11","metaId":"tt2"}, + {"dateIso":"2026-06-10","metaId":"tt3"} + ] + }); + let result: Value = + serde_json::from_str(&calendar_release_detection_json(&request.to_string()).unwrap()) + .unwrap(); + assert_eq!(result.as_array().unwrap().len(), 2); + } +} diff --git a/src/content_identity.rs b/src/content_identity.rs new file mode 100644 index 0000000..221b4d6 --- /dev/null +++ b/src/content_identity.rs @@ -0,0 +1,1372 @@ +use serde_json::{json, Map, Value}; +use std::collections::HashMap; +use std::sync::OnceLock; + +pub(crate) fn parse_episode_locator(raw: &str) -> Option<(String, i32, i32)> { + let parts = raw + .split(':') + .filter(|part| !part.is_empty()) + .collect::>(); + if parts.len() >= 3 { + let season = parts[parts.len() - 2].parse::().ok()?; + let episode = parts[parts.len() - 1].parse::().ok()?; + let base_id = parts[..parts.len() - 2].join(":"); + if !base_id.is_empty() { + return Some((base_id, season, episode)); + } + } + + let lower = raw.to_ascii_lowercase(); + let bytes = lower.as_bytes(); + for index in 0..bytes.len() { + if bytes[index] != b's' { + continue; + } + let mut cursor = index + 1; + let season_start = cursor; + while cursor < bytes.len() && bytes[cursor].is_ascii_digit() { + cursor += 1; + } + if season_start == cursor || cursor >= bytes.len() || bytes[cursor] != b'e' { + continue; + } + let episode_start = cursor + 1; + cursor = episode_start; + while cursor < bytes.len() && bytes[cursor].is_ascii_digit() { + cursor += 1; + } + if episode_start == cursor { + continue; + } + let season = lower[season_start..episode_start - 1].parse::().ok(); + let episode = lower[episode_start..cursor].parse::().ok(); + if let (Some(season), Some(episode)) = (season, episode) { + return Some((String::new(), season, episode)); + } + } + + let parts = raw + .split([':', '/', '-', '_']) + .filter(|part| !part.is_empty()) + .collect::>(); + if parts.len() >= 3 { + let season = parts[parts.len() - 2].parse::().ok()?; + let episode = parts[parts.len() - 1].parse::().ok()?; + return Some((parts[..parts.len() - 2].join(":"), season, episode)); + } + None +} + +pub(crate) fn imdb_id(raw: &str) -> Option { + imdb_regex() + .find(raw) + .map(|matched| matched.as_str().to_string()) +} + +pub(crate) fn base_content_id(id: &str) -> String { + parse_episode_locator(id) + .map(|(base_id, _, _)| { + if base_id.is_empty() { + id.to_string() + } else { + base_id + } + }) + .unwrap_or_else(|| id.to_string()) +} + +pub(crate) fn normalize_series_lookup_id(raw_id: &str) -> String { + imdb_id(raw_id).unwrap_or_else(|| base_content_id(raw_id)) +} + +pub(crate) fn is_tmdb_like_content_id(id: &str) -> bool { + let base = base_content_id(id); + base.to_ascii_lowercase().starts_with("tmdb:") || base.parse::().is_ok() +} + +pub(crate) fn episode_id(base_id: &str, season: i32, episode: i32) -> String { + format!("{base_id}:{season}:{episode}") +} + +pub(crate) fn stream_request_ids( + content_type: &str, + id: &str, + detail_id: Option<&str>, + current_series_lookup_id: Option<&str>, + canonical_base_id: Option<&str>, +) -> Vec { + let mut ids = Vec::new(); + if content_type != "series" { + if is_tmdb_like_content_id(id) { + if let Some(canonical) = canonical_base_id { + push_unique(&mut ids, canonical.to_string()); + } + } + push_unique(&mut ids, id.to_string()); + if let Some(detail) = detail_id { + push_unique(&mut ids, detail.to_string()); + } + if let Some(canonical) = canonical_base_id { + push_unique(&mut ids, canonical.to_string()); + } + return ids; + } + + let locator = parse_episode_locator(id); + let normalized_series_id = current_series_lookup_id + .map(str::to_string) + .or_else(|| detail_id.map(normalize_series_lookup_id)); + let normalized_detail_base_id = detail_id.map(base_content_id); + + if let Some((_, season, episode)) = locator { + push_unique(&mut ids, id.to_string()); + if let Some(series_id) = normalized_series_id { + push_unique(&mut ids, episode_id(&series_id, season, episode)); + } + if let Some(detail_base_id) = normalized_detail_base_id { + push_unique(&mut ids, episode_id(&detail_base_id, season, episode)); + } + push_unique(&mut ids, episode_id(&base_content_id(id), season, episode)); + if let Some(canonical) = canonical_base_id { + push_unique(&mut ids, episode_id(canonical, season, episode)); + } + } else { + push_unique(&mut ids, id.to_string()); + if let Some(series_id) = normalized_series_id { + push_unique(&mut ids, series_id); + } + if let Some(detail) = detail_id { + push_unique(&mut ids, detail.to_string()); + } + if let Some(canonical) = canonical_base_id { + push_unique(&mut ids, canonical.to_string()); + } + } + + ids +} + +pub(crate) fn playback_intro_lookup_content_id(id: &str) -> String { + if let Some(imdb) = imdb_id(id) { + return imdb; + } + base_content_id(id).trim_start_matches("tmdb:").to_string() +} + +pub(crate) fn playback_stream_request_ids_json( + content_type: &str, + id: &str, + detail_id: Option<&str>, +) -> Option { + let canonical_base_id = imdb_id(id).or_else(|| detail_id.and_then(imdb_id)); + serde_json::to_string(&stream_request_ids( + content_type, + id, + detail_id, + detail_id.map(normalize_series_lookup_id).as_deref(), + canonical_base_id.as_deref(), + )) + .ok() +} + +pub(crate) fn direct_playback_plan_json( + meta_json: &str, + detail_json: Option<&str>, + today_iso: &str, +) -> Option { + let meta: Value = serde_json::from_str(meta_json).ok()?; + let detail: Value = detail_json + .and_then(|value| serde_json::from_str(value).ok()) + .unwrap_or(Value::Null); + let has_detail = detail.as_object().is_some_and(|object| !object.is_empty()); + let playback_meta = if has_detail { + home_playback_meta(&meta, &detail) + } else { + meta.clone() + }; + let target_video_id = string_field(&meta, "lastVideoId") + .or_else(|| select_direct_playback_video_id(&detail, today_iso)); + let lookup_id = target_video_id + .clone() + .or_else(|| string_field(&detail, "id")) + .or_else(|| string_field(&meta, "id")) + .unwrap_or_default(); + + serde_json::to_string(&json!({ + "meta": playback_meta, + "targetVideoId": target_video_id, + "lookupId": lookup_id + })) + .ok() +} + +fn home_playback_meta(fallback: &Value, detail: &Value) -> Value { + let mut meta = Map::new(); + for key in [ + "id", + "name", + "type", + "poster", + "background", + "logo", + "description", + "imdbRating", + "ageRating", + "ratings", + "genres", + "releaseInfo", + "released", + "runtime", + "seasonsCount", + "cast", + "originalLanguage", + ] { + insert_detail_or_fallback(&mut meta, key, detail, fallback); + } + if meta + .get("name") + .and_then(Value::as_str) + .is_some_and(|name| name.trim().is_empty()) + { + meta.insert( + "name".to_string(), + fallback + .get("name") + .cloned() + .unwrap_or(Value::String(String::new())), + ); + } + let episodes_count = detail + .get("videos") + .and_then(Value::as_array) + .map(|videos| json!(videos.len())) + .unwrap_or_else(|| { + fallback + .get("episodesCount") + .cloned() + .unwrap_or(Value::Null) + }); + meta.insert("episodesCount".to_string(), episodes_count); + for key in [ + "timeOffset", + "duration", + "lastVideoId", + "lastStreamIndex", + "lastEpisodeName", + "lastStreamUrl", + "lastStreamTitle", + "lastAudioLanguage", + "lastSubtitleLanguage", + "awards", + "rank", + "reason", + "homeBadge", + ] { + meta.insert( + key.to_string(), + fallback.get(key).cloned().unwrap_or(Value::Null), + ); + } + Value::Object(meta) +} + +fn insert_detail_or_fallback( + target: &mut Map, + key: &str, + detail: &Value, + fallback: &Value, +) { + target.insert( + key.to_string(), + detail + .get(key) + .filter(|value| !value.is_null()) + .cloned() + .unwrap_or_else(|| fallback.get(key).cloned().unwrap_or(Value::Null)), + ); +} + +fn select_direct_playback_video_id(detail: &Value, today_iso: &str) -> Option { + if detail.get("type").and_then(Value::as_str) != Some("series") { + return None; + } + let mut videos = detail + .get("videos") + .and_then(Value::as_array)? + .iter() + .collect::>(); + videos.sort_by_key(|video| { + ( + number_field(video, "season").unwrap_or(i64::MAX), + number_field(video, "number") + .or_else(|| number_field(video, "episode")) + .unwrap_or(i64::MAX), + ) + }); + videos + .iter() + .find(|video| { + !string_field(video, "released") + .as_deref() + .is_some_and(|released| is_upcoming_iso(released, today_iso)) + }) + .copied() + .or_else(|| videos.first().copied()) + .and_then(|video| string_field(video, "id")) +} + +fn is_upcoming_iso(value: &str, today_iso: &str) -> bool { + let date = value.trim().get(0..10).unwrap_or("").to_string(); + date.len() == 10 && date.as_str() > today_iso +} + +pub(crate) fn stream_discovery_episode_context_json( + content_type: &str, + request_id: &str, + detail_json: Option<&str>, + season_episodes_json: &str, +) -> Option { + let season_episodes: Vec = + serde_json::from_str(season_episodes_json).unwrap_or_default(); + let detail: Value = detail_json + .and_then(|value| serde_json::from_str(value).ok()) + .unwrap_or(Value::Null); + + let expected_episode_titles = if content_type == "series" { + season_episodes + .iter() + .find(|episode| string_field(episode, "id").as_deref() == Some(request_id)) + .or_else(|| { + detail + .get("videos") + .and_then(Value::as_array) + .and_then(|videos| { + videos.iter().find(|episode| { + string_field(episode, "id").as_deref() == Some(request_id) + }) + }) + }) + .and_then(|episode| string_field(episode, "name")) + .map(|title| vec![title]) + .unwrap_or_default() + } else { + Vec::new() + }; + + let mut season_episode_titles = Map::new(); + let mut season_episode_ids = Map::new(); + if content_type == "series" { + for episode in &season_episodes { + let Some(number) = number_field(episode, "number") else { + continue; + }; + if let Some(title) = string_field(episode, "name") { + let key = number.to_string(); + let values = season_episode_titles + .entry(key) + .or_insert_with(|| Value::Array(Vec::new())); + if let Some(values) = values.as_array_mut() { + if !values + .iter() + .any(|value| value.as_str() == Some(title.as_str())) + { + values.push(Value::String(title)); + } + } + } + if let Some(id) = string_field(episode, "id") { + season_episode_ids + .entry(number.to_string()) + .or_insert(Value::String(id)); + } + } + } + + serde_json::to_string(&serde_json::json!({ + "expectedEpisodeTitles": expected_episode_titles, + "seasonEpisodeTitles": season_episode_titles, + "seasonEpisodeIds": season_episode_ids + })) + .ok() +} + +fn string_field(value: &Value, key: &str) -> Option { + value + .get(key) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + +fn number_field(value: &Value, key: &str) -> Option { + value.get(key).and_then(Value::as_i64) +} + +pub(crate) fn percent_decode_component(value: &str) -> String { + let mut bytes = Vec::with_capacity(value.len()); + let raw = value.as_bytes(); + let mut index = 0; + while index < raw.len() { + if raw[index] == b'%' && index + 2 < raw.len() { + if let Ok(hex) = u8::from_str_radix(&value[index + 1..index + 3], 16) { + bytes.push(hex); + index += 3; + continue; + } + } + bytes.push(raw[index]); + index += 1; + } + String::from_utf8_lossy(&bytes).into_owned() +} + +pub(crate) fn form_decode(value: &str) -> String { + percent_decode_component(&value.replace('+', " ")) +} + +pub(crate) fn stable_feed_part(value: &str) -> String { + let mut output = String::with_capacity(value.len()); + let mut replaced = false; + for ch in value.to_lowercase().chars() { + if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-') { + output.push(ch); + replaced = false; + } else if !replaced { + output.push('_'); + replaced = true; + } + } + output.trim_matches('_').to_string() +} + +pub(crate) fn normalize_content_type(value: &str) -> Option<&'static str> { + match value.to_lowercase().as_str() { + "movie" | "movies" => Some("movie"), + "series" | "tv" | "show" | "shows" | "anime" => Some("series"), + _ => None, + } +} + +fn collapse_whitespace(value: &str) -> String { + value.split_whitespace().collect::>().join(" ") +} + +pub(crate) fn normalize_provider_search_text(value: &str) -> String { + collapse_whitespace( + &value + .to_lowercase() + .replace(['+', '-', '_'], " ") + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || ch == ' ' { + ch + } else { + ' ' + } + }) + .collect::(), + ) +} + +pub(crate) fn provider_search_terms(provider: &str) -> Vec { + match provider.trim().to_lowercase().as_str() { + "8" => vec!["netflix".to_string()], + "9" => vec!["prime".to_string(), "amazon".to_string()], + "337" => vec!["disney".to_string()], + "49" => vec!["hbo".to_string(), "max".to_string()], + "350" => vec!["apple".to_string()], + _ => { + let normalized = normalize_provider_search_text(provider); + if normalized.is_empty() { + Vec::new() + } else { + vec![normalized] + } + } + } +} + +pub(crate) fn parse_string_list(json: &str) -> Vec { + serde_json::from_str::>>(json) + .ok() + .flatten() + .unwrap_or_default() +} + +pub(crate) fn json_string_list(values: &[String]) -> Option { + serde_json::to_string(values).ok() +} + +pub(crate) fn effective_metadata_feed_selection_json( + selected_keys_json: &str, + available_keys_json: &str, +) -> Option { + let selected = serde_json::from_str::>>(selected_keys_json) + .ok() + .flatten()?; + let available = parse_string_list(available_keys_json); + let filtered = selected + .into_iter() + .filter(|key| available.contains(key)) + .collect::>(); + json_string_list(&filtered) +} + +pub(crate) fn toggle_metadata_feed_json( + selected_keys_json: &str, + available_keys_json: &str, + key: &str, +) -> Option { + let selected = serde_json::from_str::>>(selected_keys_json) + .ok() + .flatten(); + let current = selected.unwrap_or_else(|| parse_string_list(available_keys_json)); + let mut output = Vec::::new(); + let mut contains = false; + for item in current { + if item == key { + contains = true; + } else if !output.contains(&item) { + output.push(item); + } + } + if !contains { + output.push(key.to_string()); + } + json_string_list(&output) +} + +pub(crate) fn toggle_metadata_feed_limited_json( + selected_keys_json: &str, + available_keys_json: &str, + key: &str, + max_enabled: i32, +) -> Option { + let current = serde_json::from_str::>>(selected_keys_json) + .ok() + .flatten() + .unwrap_or_else(|| parse_string_list(available_keys_json)); + let output: Vec = if current.iter().any(|item| item == key) { + current.into_iter().filter(|item| item != key).collect() + } else { + let mut appended = current; + appended.push(key.to_string()); + let keep = max_enabled.max(0) as usize; + appended + .into_iter() + .rev() + .take(keep) + .collect::>() + .into_iter() + .rev() + .collect() + }; + json_string_list(&output) +} + +pub(crate) fn set_metadata_feed_group_enabled_json( + selected_keys_json: &str, + available_keys_json: &str, + group_keys_json: &str, + enabled: bool, +) -> Option { + let current = serde_json::from_str::>>(selected_keys_json) + .ok() + .flatten() + .unwrap_or_else(|| parse_string_list(available_keys_json)); + let group = parse_string_list(group_keys_json); + let mut output = Vec::::new(); + for item in current { + if enabled || !group.contains(&item) { + if !output.contains(&item) { + output.push(item); + } + } + } + if enabled { + for item in group { + if !output.contains(&item) { + output.push(item); + } + } + } + json_string_list(&output) +} + +pub(crate) fn ordered_metadata_feed_keys( + option_keys_json: &str, + order_json: &str, +) -> Option { + let option_keys = parse_string_list(option_keys_json); + let order = parse_string_list(order_json); + let mut output = Vec::::new(); + for key in order { + if option_keys.contains(&key) && !output.contains(&key) { + output.push(key); + } + } + for key in option_keys { + if !output.contains(&key) { + output.push(key); + } + } + json_string_list(&output) +} + +pub(crate) fn move_metadata_feed_order_json( + option_keys_json: &str, + current_order_json: &str, + key: &str, + delta: i32, +) -> Option { + let ordered_json = ordered_metadata_feed_keys(option_keys_json, current_order_json)?; + let mut keys = parse_string_list(&ordered_json); + let Some(from) = keys.iter().position(|item| item == key) else { + return json_string_list(&keys); + }; + if keys.is_empty() { + return json_string_list(&keys); + } + let to = (from as i32 + delta).clamp(0, keys.len() as i32 - 1) as usize; + if from != to { + let moved = keys.remove(from); + keys.insert(to, moved); + } + json_string_list(&keys) +} + +pub(crate) fn normalized_billboard_title(value: &str) -> String { + collapse_whitespace( + &value + .to_lowercase() + .replace('ç', "c") + .replace('ğ', "g") + .replace('ı', "i") + .replace('ö', "o") + .replace('ş', "s") + .replace('ü', "u") + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || ch == ' ' { + ch + } else { + ' ' + } + }) + .collect::(), + ) +} + +pub(crate) fn content_trakt_key_value(meta: &Value) -> String { + trakt_identity_key(meta) +} + +pub(crate) fn content_merge_keys_value(meta: &Value) -> Vec { + let mut keys = Vec::new(); + let id = meta_text(meta, "id"); + push_unique(&mut keys, content_trakt_key_value(meta)); + push_unique(&mut keys, id.to_string()); + push_unique(&mut keys, base_content_id(id)); + if let Some(imdb) = imdb_id(id) { + push_unique(&mut keys, imdb); + } + if let Some(key) = title_year_key(meta) { + push_unique(&mut keys, key); + } + keys +} + +pub(crate) fn content_watched_keys_value(meta: &Value) -> Vec { + let mut keys = Vec::new(); + push_unique(&mut keys, content_trakt_key_value(meta)); + push_unique(&mut keys, meta_text(meta, "id").to_string()); + if let Some(key) = title_year_key(meta) { + push_unique(&mut keys, key); + } + keys +} + +pub(crate) fn content_trakt_key(meta_json: &str) -> Option { + let meta = serde_json::from_str::(meta_json).ok()?; + Some(content_trakt_key_value(&meta)) +} + +pub(crate) fn content_billboard_key(meta_json: &str) -> Option { + let meta = serde_json::from_str::(meta_json).ok()?; + let id = meta_text(&meta, "id"); + if let Some(imdb) = imdb_id(id) { + return Some(format!("{}:{imdb}", meta_text(&meta, "type"))); + } + let name = meta + .get("originalName") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| meta_text(&meta, "name")); + let year = meta_text(&meta, "releaseInfo") + .get(0..4) + .or_else(|| meta_text(&meta, "released").get(0..4)) + .unwrap_or(""); + Some(format!( + "{}:{}:{year}", + meta_text(&meta, "type"), + normalized_billboard_title(name) + )) +} + +pub(crate) fn content_keys_json(meta_json: &str, watched: bool) -> Option { + let meta = serde_json::from_str::(meta_json).ok()?; + let keys = if watched { + content_watched_keys_value(&meta) + } else { + content_merge_keys_value(&meta) + }; + serde_json::to_string(&keys).ok() +} + +pub(crate) fn episode_filename_candidate(stream_json: &str, video_id: &str) -> Option { + let (_, season, episode) = parse_episode_locator(video_id)?; + let stream = serde_json::from_str::(stream_json).ok()?; + for value in ["title", "description", "name"] { + if let Some(text) = stream.get(value).and_then(Value::as_str) { + for line in text.lines().map(str::trim) { + if is_likely_video_file(line) && text_matches_episode(line, season, episode) { + return Some(line.to_string()); + } + } + } + } + None +} + +pub(crate) fn is_likely_video_file(path: &str) -> bool { + let path = path.to_ascii_lowercase(); + [".mkv", ".mp4", ".avi", ".webm", ".m4v", ".mov", ".ts"] + .iter() + .any(|extension| path.ends_with(extension)) +} + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct StreamDiscoveryCacheKeyRequest { + #[serde(rename = "type")] + content_type: String, + id: String, + language: String, + cs3_search_query: Option, + cs3_year: Option, + cs3_original_name: Option, + addon_signatures: Vec, + cs3_plugin_names: Vec, +} + +pub(crate) fn java_string_hash(value: &str) -> i32 { + let mut hash = 0i32; + for unit in value.encode_utf16() { + hash = hash.wrapping_mul(31).wrapping_add(unit as i32); + } + hash +} + +pub(crate) fn stream_discovery_cache_key(request_json: &str) -> Option { + let mut request = serde_json::from_str::(request_json).ok()?; + let mut addon_signatures = request.addon_signatures; + addon_signatures.sort(); + let mut cs3_plugin_names = request.cs3_plugin_names; + cs3_plugin_names.sort(); + let search_query = request.cs3_search_query.unwrap_or_default(); + let original_name_hash = request + .cs3_original_name + .take() + .filter(|value| value != &search_query) + .map(|value| java_string_hash(&value).to_string()) + .unwrap_or_default(); + Some( + [ + request.content_type, + request.id, + request.language, + search_query, + request + .cs3_year + .map(|value| value.to_string()) + .unwrap_or_default(), + original_name_hash, + addon_signatures.join("|"), + cs3_plugin_names.join("|"), + ] + .join("|"), + ) +} + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DiscoverCatalogCacheKeyRequest { + #[serde(rename = "type")] + content_type: String, + catalog_key: Option, + genre: Option, + year: Option, + rating: Option, + provider: Option, + region: Option, + catalog_signatures: Vec, +} + +pub(crate) fn discover_catalog_cache_key(request_json: &str) -> Option { + let request = serde_json::from_str::(request_json).ok()?; + Some( + [ + request.content_type, + request.catalog_key.unwrap_or_default(), + request.genre.unwrap_or_default(), + request.year.unwrap_or_default(), + request + .rating + .map(|value| value.to_string()) + .unwrap_or_default(), + request.provider.unwrap_or_default(), + request.region.unwrap_or_default(), + request.catalog_signatures.join(","), + ] + .join("|"), + ) +} + +pub(crate) fn parse_extra_args_json(extra: &str) -> Option { + let mut map = Map::new(); + for part in extra.split('&') { + let key = part.split_once('=').map(|(key, _)| key).unwrap_or(part); + if key.is_empty() { + continue; + } + let value = part.split_once('=').map(|(_, value)| value).unwrap_or(""); + map.insert(form_decode(key), Value::String(form_decode(value))); + } + serde_json::to_string(&Value::Object(map)).ok() +} + +pub(crate) fn contains_compact_episode(text: &str, season: i32, episode: i32) -> bool { + let lower = text.to_ascii_lowercase(); + let bytes = lower.as_bytes(); + for index in 0..bytes.len() { + if bytes[index] != b's' { + continue; + } + let mut cursor = index + 1; + let season_start = cursor; + while cursor < bytes.len() && bytes[cursor].is_ascii_digit() { + cursor += 1; + } + if season_start == cursor || cursor >= bytes.len() || bytes[cursor] != b'e' { + continue; + } + let episode_start = cursor + 1; + cursor = episode_start; + while cursor < bytes.len() && bytes[cursor].is_ascii_digit() { + cursor += 1; + } + if episode_start == cursor { + continue; + } + let parsed_season = lower[season_start..episode_start - 1].parse::().ok(); + let parsed_episode = lower[episode_start..cursor].parse::().ok(); + let next_is_digit = cursor < bytes.len() && bytes[cursor].is_ascii_digit(); + if parsed_season == Some(season) && parsed_episode == Some(episode) && !next_is_digit { + return true; + } + } + false +} + +pub(crate) fn contains_spaced_episode(text: &str, season: i32, episode: i32) -> bool { + let lower = text.to_ascii_lowercase(); + let mut offset = 0; + while let Some(season_index) = lower[offset..].find("season") { + let mut cursor = offset + season_index + "season".len(); + while lower + .as_bytes() + .get(cursor) + .is_some_and(u8::is_ascii_whitespace) + { + cursor += 1; + } + let season_start = cursor; + while lower.as_bytes().get(cursor).is_some_and(u8::is_ascii_digit) { + cursor += 1; + } + if season_start == cursor || lower[season_start..cursor].parse::().ok() != Some(season) + { + offset = cursor.saturating_add(1); + continue; + } + let Some(episode_word_index) = lower[cursor..].find("episode") else { + return false; + }; + cursor += episode_word_index + "episode".len(); + while lower + .as_bytes() + .get(cursor) + .is_some_and(u8::is_ascii_whitespace) + { + cursor += 1; + } + let episode_start = cursor; + while lower.as_bytes().get(cursor).is_some_and(u8::is_ascii_digit) { + cursor += 1; + } + let next_is_digit = lower.as_bytes().get(cursor).is_some_and(u8::is_ascii_digit); + if episode_start != cursor + && lower[episode_start..cursor].parse::().ok() == Some(episode) + && !next_is_digit + { + return true; + } + offset = cursor.saturating_add(1); + } + false +} + +pub(crate) fn text_matches_episode(text: &str, season: i32, episode: i32) -> bool { + contains_compact_episode(text, season, episode) + || contains_spaced_episode(text, season, episode) +} + +pub(crate) fn stream_matches_episode(video_id: &str, fields: &[String]) -> bool { + let Some((_, season, episode)) = parse_episode_locator(video_id) else { + return true; + }; + let text = fields + .iter() + .filter(|value| !value.trim().is_empty()) + .cloned() + .collect::>() + .join(" "); + if text.trim().is_empty() { + return true; + } + if text_matches_episode(&text, season, episode) { + return true; + } + !contains_any_compact_episode(&text) +} + +pub(crate) fn contains_any_compact_episode(text: &str) -> bool { + let lower = text.to_ascii_lowercase(); + let bytes = lower.as_bytes(); + for index in 0..bytes.len() { + if bytes[index] != b's' { + continue; + } + let mut cursor = index + 1; + let season_start = cursor; + while cursor < bytes.len() && bytes[cursor].is_ascii_digit() { + cursor += 1; + } + if season_start == cursor || season_start + 2 < cursor { + continue; + } + if cursor >= bytes.len() || bytes[cursor] != b'e' { + continue; + } + let episode_start = cursor + 1; + cursor = episode_start; + while cursor < bytes.len() && bytes[cursor].is_ascii_digit() { + cursor += 1; + } + if episode_start != cursor && cursor - episode_start <= 3 { + return true; + } + } + false +} + +pub(crate) fn imdb_regex() -> &'static regex::Regex { + pub(crate) static REGEX: OnceLock = OnceLock::new(); + REGEX.get_or_init(|| regex::Regex::new(r"tt\d+").expect("valid imdb regex")) +} + +pub(crate) fn year_regex() -> &'static regex::Regex { + pub(crate) static REGEX: OnceLock = OnceLock::new(); + REGEX.get_or_init(|| regex::Regex::new(r"\d{4}").expect("valid year regex")) +} + +pub(crate) fn meta_text<'a>(meta: &'a Value, key: &str) -> &'a str { + meta.get(key).and_then(Value::as_str).unwrap_or("") +} + +pub(crate) fn normalized_loose_title(value: &str) -> String { + value + .to_lowercase() + .chars() + .map(|ch| { + if ch.is_ascii_lowercase() || ch.is_ascii_digit() { + ch + } else { + ' ' + } + }) + .collect::() + .trim() + .to_string() +} + +pub(crate) fn title_year_key(meta: &Value) -> Option { + let title = normalized_loose_title(meta_text(meta, "name")); + if title.is_empty() { + return None; + } + let year = year_regex() + .find(meta_text(meta, "releaseInfo")) + .map(|matched| matched.as_str()) + .unwrap_or(""); + Some(format!("{}:{title}:{year}", meta_text(meta, "type"))) +} + +pub(crate) fn trakt_identity_key(meta: &Value) -> String { + let id = meta_text(meta, "id"); + if let Some(value) = imdb_regex().find(id).map(|matched| matched.as_str()) { + return value.to_string(); + } + let tmdb = if id.to_ascii_lowercase().starts_with("tmdb:") { + id.strip_prefix("tmdb:").unwrap_or(id) + } else { + "" + }; + if !tmdb.is_empty() { + return format!("tmdb:{tmdb}"); + } + format!( + "{}:{}:{}", + meta_text(meta, "type"), + normalized_loose_title(meta_text(meta, "name")), + meta_text(meta, "releaseInfo") + ) +} + +pub(crate) fn push_unique(values: &mut Vec, value: String) { + if !value.is_empty() && !values.contains(&value) { + values.push(value); + } +} + +pub(crate) fn continue_watching_merge_keys(meta: &Value) -> Vec { + let mut keys = Vec::new(); + push_unique(&mut keys, trakt_identity_key(meta)); + let id = meta_text(meta, "id"); + let base_id = if parse_episode_locator(id).is_some() { + parse_episode_locator(id) + .map(|(base_id, _, _)| { + if base_id.is_empty() { + id.to_string() + } else { + base_id + } + }) + .unwrap_or_else(|| id.to_string()) + } else { + id.to_string() + }; + push_unique(&mut keys, id.to_string()); + push_unique(&mut keys, base_id); + if let Some(value) = imdb_regex().find(id).map(|matched| matched.as_str()) { + push_unique(&mut keys, value.to_string()); + } + if let Some(value) = title_year_key(meta) { + push_unique(&mut keys, value); + } + keys +} + +pub(crate) fn is_trakt_continue_watching_source(meta: &Value) -> bool { + meta_text(meta, "reason").eq_ignore_ascii_case("Trakt.tv") +} + +pub(crate) fn merge_continue_watching_duplicates_json(items_json: &str) -> Option { + let items = serde_json::from_str::>(items_json).ok()?; + let mut merged: Vec = Vec::new(); + let mut key_to_index: HashMap = HashMap::new(); + let mut aliases: HashMap = HashMap::new(); + + for item in items { + let item_keys = continue_watching_merge_keys(&item); + let key = item_keys + .iter() + .find_map(|item_key| aliases.get(item_key).cloned()) + .or_else(|| item_keys.first().cloned()) + .unwrap_or_default(); + if key.is_empty() { + merged.push(item); + continue; + } + + if let Some(index) = key_to_index.get(&key).copied() { + if is_trakt_continue_watching_source(&item) + || !is_trakt_continue_watching_source(&merged[index]) + { + merged[index] = item; + } + } else { + key_to_index.insert(key.clone(), merged.len()); + merged.push(item); + } + for item_key in item_keys { + aliases.insert(item_key, key.clone()); + } + } + + serde_json::to_string(&merged).ok() +} + +pub(crate) fn meta_year(meta: &Value) -> Option { + ["released", "releaseInfo"].into_iter().find_map(|key| { + year_regex() + .find(meta_text(meta, key)) + .map(|matched| matched.as_str().to_string()) + }) +} + +pub(crate) fn rating_value(value: &Value) -> Option { + let text = match value { + Value::String(text) => text.trim().to_string(), + Value::Number(number) => number.to_string(), + _ => value.to_string().trim_matches('"').trim().to_string(), + }; + if text.is_empty() { + return None; + } + if let Some((score, scale)) = text.split_once('/') { + let score = score.trim().parse::().ok()?; + let scale = scale.trim().parse::().ok()?; + if scale == 0.0 { + None + } else { + Some((score / scale) * 10.0) + } + } else if let Some(percent) = text.strip_suffix('%') { + percent.trim().parse::().ok().map(|value| value / 10.0) + } else { + text.parse::().ok() + } +} + +pub(crate) fn meta_rating(meta: &Value) -> Option { + meta.get("imdbRating") + .and_then(Value::as_str) + .and_then(|value| value.parse::().ok()) + .or_else(|| { + meta.get("ratings") + .and_then(Value::as_array) + .and_then(|ratings| { + ratings + .iter() + .find_map(|rating| rating.get("value").and_then(rating_value)) + }) + }) +} + +pub(crate) fn matches_discover_year(meta: &Value, year: Option<&str>) -> bool { + let Some(expected) = year.map(str::trim).filter(|value| !value.is_empty()) else { + return true; + }; + meta_year(meta).as_deref() == Some(expected) +} + +pub(crate) fn matches_discover_rating(meta: &Value, minimum_rating: Option) -> bool { + let Some(minimum) = minimum_rating else { + return true; + }; + meta_rating(meta).is_some_and(|candidate| candidate >= minimum) +} + +pub(crate) fn matches_discover_region(meta: &Value, region: Option<&str>) -> bool { + let Some(expected) = region + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| value.to_lowercase()) + else { + return true; + }; + let language = meta_text(meta, "originalLanguage").to_lowercase(); + if language.is_empty() { + return false; + } + match expected.as_str() { + "us" | "usa" | "en" => language == "en", + "jp" | "ja" | "japan" => language == "ja", + "kr" | "ko" | "korea" => language == "ko", + _ => language == expected, + } +} + +pub(crate) fn filter_discover_results_json( + items_json: &str, + year: Option<&str>, + rating: Option, + region: Option<&str>, +) -> Option { + let items = serde_json::from_str::>(items_json).ok()?; + let filtered = items + .into_iter() + .filter(|item| matches_discover_year(item, year)) + .filter(|item| matches_discover_rating(item, rating)) + .filter(|item| matches_discover_region(item, region)) + .collect::>(); + serde_json::to_string(&filtered).ok() +} + +pub(crate) fn parse_video_id_json(id: &str) -> String { + let parts: Vec<&str> = id.split(':').collect(); + let mut map = serde_json::Map::new(); + if parts.first().map(|p| p.starts_with("tt")).unwrap_or(false) { + map.insert("imdb".into(), parts[0].into()); + if parts.len() >= 3 { + if let (Ok(s), Ok(e)) = (parts[1].parse::(), parts[2].parse::()) { + map.insert("season".into(), s.into()); + map.insert("episode".into(), e.into()); + map.insert("isEpisode".into(), true.into()); + } else { + map.insert("isEpisode".into(), false.into()); + } + } else { + map.insert("isEpisode".into(), false.into()); + } + } else if parts.first().map(|p| *p == "tmdb").unwrap_or(false) && parts.len() >= 2 { + map.insert("tmdb".into(), parts[1].into()); + if parts.len() >= 4 { + if let (Ok(s), Ok(e)) = (parts[2].parse::(), parts[3].parse::()) { + map.insert("season".into(), s.into()); + map.insert("episode".into(), e.into()); + map.insert("isEpisode".into(), true.into()); + } else { + map.insert("isEpisode".into(), false.into()); + } + } else { + map.insert("isEpisode".into(), false.into()); + } + } else { + map.insert("isEpisode".into(), false.into()); + } + serde_json::to_string(&serde_json::Value::Object(map)).unwrap_or_else(|_| r#"{"isEpisode":false}"#.to_string()) +} + +pub(crate) fn build_trakt_ids_json(video_id: &str) -> Option { + let parsed_json = parse_video_id_json(video_id); + let parsed: serde_json::Value = serde_json::from_str(&parsed_json).ok()?; + if let Some(imdb) = parsed.get("imdb").and_then(serde_json::Value::as_str) { + return serde_json::to_string(&serde_json::json!({"imdb": imdb})).ok(); + } + if let Some(tmdb) = parsed.get("tmdb").and_then(serde_json::Value::as_str) { + if let Ok(n) = tmdb.parse::() { + return serde_json::to_string(&serde_json::json!({"tmdb": n})).ok(); + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn playback_intro_lookup_prefers_imdb_then_base_tmdb_number() { + assert_eq!(playback_intro_lookup_content_id("tmdb:42:1:2"), "42"); + assert_eq!( + playback_intro_lookup_content_id("tt1234567:1:2"), + "tt1234567" + ); + } + + #[test] + fn playback_stream_request_ids_use_detail_imdb_as_canonical_id() { + let ids = playback_stream_request_ids_json("movie", "tmdb:42", Some("tt1234567")) + .and_then(|json| serde_json::from_str::>(&json).ok()) + .expect("ids"); + + assert_eq!(ids, vec!["tt1234567", "tmdb:42"]); + } + + #[test] + fn direct_playback_plan_selects_first_released_episode_without_mutating_provider_streams() { + let plan = direct_playback_plan_json( + r#"{"id":"tt1","name":"Fallback","type":"series","description":"fallback","lastStreamIndex":3}"#, + Some( + r#"{"id":"tt1","name":"Detail","type":"series","poster":"p","videos":[{"id":"tt1:1:2","season":1,"number":2,"released":"2026-06-01T00:00:00.000Z"},{"id":"tt1:1:1","season":1,"number":1,"released":"2026-05-01T00:00:00.000Z"}]}"#, + ), + "2026-05-21", + ) + .and_then(|json| serde_json::from_str::(&json).ok()) + .expect("plan"); + + assert_eq!(plan["targetVideoId"], "tt1:1:1"); + assert_eq!(plan["lookupId"], "tt1:1:1"); + assert_eq!(plan["meta"]["name"], "Detail"); + assert_eq!(plan["meta"]["description"], "fallback"); + assert_eq!(plan["meta"]["episodesCount"], 2); + assert_eq!(plan["meta"]["lastStreamIndex"], 3); + } + + #[test] + fn direct_playback_plan_prefers_saved_video_and_falls_back_to_meta_without_detail() { + let plan = direct_playback_plan_json( + r#"{"id":"tt1","name":"Movie","type":"movie","lastVideoId":"tt1:2:3"}"#, + None, + "2026-05-21", + ) + .and_then(|json| serde_json::from_str::(&json).ok()) + .expect("plan"); + + assert_eq!(plan["targetVideoId"], "tt1:2:3"); + assert_eq!(plan["lookupId"], "tt1:2:3"); + assert_eq!(plan["meta"]["name"], "Movie"); + } + + #[test] + fn effective_metadata_feed_selection_preserves_explicit_empty_selection() { + assert_eq!( + effective_metadata_feed_selection_json("null", r#"["a","b"]"#), + None + ); + assert_eq!( + effective_metadata_feed_selection_json("[]", r#"["a","b"]"#).as_deref(), + Some("[]") + ); + assert_eq!( + effective_metadata_feed_selection_json(r#"["old"]"#, r#"["a","b"]"#).as_deref(), + Some("[]") + ); + assert_eq!( + effective_metadata_feed_selection_json(r#"["a","old"]"#, r#"["a","b"]"#).as_deref(), + Some(r#"["a"]"#) + ); + } + + #[test] + fn stream_discovery_episode_context_preserves_episode_order() { + let context = stream_discovery_episode_context_json( + "series", + "tt1:1:2", + Some(r#"{"videos":[{"id":"tt1:1:2","name":"From detail"}]}"#), + r#"[{"id":"tt1:1:1","number":1,"name":"Pilot"},{"id":"tt1:1:2","number":2,"name":"Second"}]"#, + ) + .and_then(|json| serde_json::from_str::(&json).ok()) + .expect("context"); + + assert_eq!( + context + .get("expectedEpisodeTitles") + .and_then(Value::as_array) + .and_then(|items| items.first()) + .and_then(Value::as_str), + Some("Second") + ); + assert_eq!( + context + .get("seasonEpisodeIds") + .and_then(Value::as_object) + .and_then(|ids| ids.get("2")) + .and_then(Value::as_str), + Some("tt1:1:2") + ); + } +} diff --git a/src/core_api.rs b/src/core_api.rs new file mode 100644 index 0000000..1a65296 --- /dev/null +++ b/src/core_api.rs @@ -0,0 +1,1168 @@ +use crate::addon_protocol; +use crate::addon_resource; +use crate::addon_store; +use crate::app_state; +use crate::calendar_plan; +use crate::content_identity; +use crate::core_contract; +use crate::data_policy; +use crate::discovery_plan; +use crate::external_sync; +use crate::headless_adapter_plan; +use crate::headless_engine; +use crate::home_ranking; +use crate::intro_segments; +use crate::library_state; +use crate::offline_download; +use crate::platform_plan; +use crate::player_flow; +use crate::player_policy; +use crate::player_scrobble; +use crate::profile_prefs; +use crate::profile_contract; +use crate::repository_flow; +use crate::stream_policy; +use crate::search_plan; +use crate::tmdb_plan; +use crate::watchlist_plan; +use serde_json::json; + +/// Platform-neutral Fluxa runtime API. +/// +/// This surface deliberately avoids Android/JNI types. Platform shells can call +/// it directly from Rust, or build their own thin FFI/UniFFI/WASM adapter on top. +pub struct FluxaCore; + +impl FluxaCore { + pub fn normalize_manifest_url(raw_url: &str) -> String { + addon_protocol::normalize_manifest_url(raw_url) + } + + pub fn identity(raw_url: &str) -> String { + addon_protocol::identity(raw_url) + } + + pub fn manifest_candidates(raw_url: &str) -> Vec { + addon_protocol::manifest_candidates(raw_url) + } + + pub fn manifest_fetch_plan_json(raw_url: &str) -> Option { + addon_protocol::manifest_fetch_plan_json(raw_url) + } + + pub fn base_url(raw_url: &str) -> String { + addon_protocol::base_url(raw_url) + } + + pub fn prefer_https_asset_url(raw_url: &str) -> Option { + addon_protocol::prefer_https_asset_url(raw_url) + } + + pub fn build_resource_url( + transport_url: &str, + resource: &str, + content_type: &str, + id: &str, + extra_json: Option<&str>, + ) -> String { + addon_protocol::build_resource_url(transport_url, resource, content_type, id, extra_json) + } + + pub fn parse_manifest_json( + body: &str, + transport_url: &str, + unknown_name: &str, + ) -> Option { + addon_protocol::parse_manifest(body, transport_url, unknown_name) + } + + pub fn resolve_manifest_assets_json(descriptor_json: &str) -> Option { + addon_protocol::resolve_manifest_assets_json(descriptor_json) + } + + pub fn merge_live_manifest_json( + descriptor_json: &str, + live_json: Option<&str>, + unknown_name: &str, + ) -> Option { + addon_protocol::merge_live_manifest_json(descriptor_json, live_json, unknown_name) + } + + pub fn supports_resource( + manifest_json: &str, + resource_name: &str, + content_type: Option<&str>, + id: Option<&str>, + ) -> bool { + addon_protocol::supports_resource(manifest_json, resource_name, content_type, id) + } + + pub fn catalog_supports_extra(catalog_json: &str, extra_name: &str) -> bool { + addon_protocol::catalog_supports_extra(catalog_json, extra_name) + } + + pub fn catalog_requires_extra(catalog_json: &str, extra_name: &str) -> bool { + addon_protocol::catalog_requires_extra(catalog_json, extra_name) + } + + pub fn catalog_has_required_extra_except(catalog_json: &str, allowed_names_json: &str) -> bool { + addon_protocol::catalog_has_required_extra_except(catalog_json, allowed_names_json) + } + + pub fn parse_addon_resource_result_json( + resource: &str, + url: &str, + status_code: i32, + body: Option<&str>, + ) -> String { + addon_resource::parse_addon_resource_result_json(resource, url, status_code, body) + } + + pub fn normalize_addon_subtitles_json(subtitles_json: &str, resource_url: &str) -> String { + addon_resource::normalize_addon_subtitles_json(subtitles_json, resource_url) + } + + pub fn cache_entry_policy_json(request_json: &str) -> Option { + data_policy::cache_entry_policy_json(request_json) + } + + pub fn cache_trim_policy_json(request_json: &str) -> Option { + data_policy::cache_trim_policy_json(request_json) + } + + pub fn data_failure_policy_json(request_json: &str) -> Option { + data_policy::data_failure_policy_json(request_json) + } + + pub fn headless_provider_availability_plan_json(request_json: &str) -> Option { + headless_adapter_plan::provider_availability_plan_json(request_json) + } + + pub fn headless_detail_stream_result_plan_json(request_json: &str) -> Option { + headless_adapter_plan::detail_stream_result_plan_json(request_json) + } + + pub fn headless_prefetch_detail_streams_plan_json(request_json: &str) -> Option { + headless_adapter_plan::prefetch_detail_streams_plan_json(request_json) + } + + pub fn headless_direct_playback_policy_json() -> String { + headless_adapter_plan::direct_playback_policy_json() + } + + pub fn create_app_core_state(initial_json: &str) -> u64 { + app_state::create_app_core_state(initial_json) + } + + pub fn destroy_app_core_state(handle: u64) -> bool { + app_state::destroy_app_core_state(handle) + } + + pub fn app_core_state_json(handle: u64) -> Option { + app_state::app_core_state_json(handle) + } + + pub fn app_core_dispatch_json(handle: u64, action_json: &str) -> Option { + app_state::app_core_dispatch_json(handle, action_json) + } + + pub fn create_headless_engine(initial_json: &str) -> u64 { + headless_engine::create_headless_engine(initial_json) + } + + pub fn destroy_headless_engine(handle: u64) -> bool { + headless_engine::destroy_headless_engine(handle) + } + + pub fn headless_engine_snapshot_json(handle: u64) -> Option { + headless_engine::headless_engine_snapshot_json(handle) + } + + pub fn headless_engine_dispatch_json(handle: u64, action_json: &str) -> Option { + headless_engine::headless_engine_dispatch_json(handle, action_json) + } + + pub fn headless_engine_complete_effect_json(handle: u64, result_json: &str) -> Option { + headless_engine::headless_engine_complete_effect_json(handle, result_json) + } + + pub fn core_capabilities_json(portable: bool) -> String { + core_contract::core_capabilities_json(portable) + } + + pub fn addon_store_input_type(raw: &str) -> &'static str { + addon_store::addon_store_input_type(raw) + } + + pub fn normalize_cloudstream_repo_url(raw: &str) -> String { + addon_store::normalize_cloudstream_repo_url(raw) + } + + pub fn normalize_plugin_repository_url(raw: &str) -> String { + addon_store::normalize_plugin_repository_url(raw) + } + + pub fn is_secure_remote_url(raw: &str) -> bool { + addon_store::is_secure_remote_url(raw) + } + + pub fn same_plugin_repository_url(left: &str, right: &str) -> bool { + addon_store::same_plugin_repository_url(left, right) + } + + pub fn profile_local_addons_key_json(profile_json: &str) -> Option { + addon_store::profile_local_addons_key_json(profile_json) + } + + pub fn sanitize_profile_json( + profile_json: &str, + mirrored_addons_json: &str, + merge_mirrored_addons: bool, + ) -> Option { + addon_store::sanitize_profile_json( + profile_json, + mirrored_addons_json, + merge_mirrored_addons, + ) + } + + pub fn addon_store_search_policy_json(request_json: &str) -> Option { + addon_store::addon_store_search_policy_json(request_json) + } + + pub fn extract_addon_manifest_url(detail_text: &str) -> Option { + addon_store::extract_addon_manifest_url(detail_text) + } + + pub fn search_result_grouping_json(request_json: &str) -> Option { + search_plan::search_result_grouping_json(request_json) + } + + pub fn build_metadata_feed_options_json(addons_json: &str) -> Option { + search_plan::build_metadata_feed_options_json(addons_json) + } + + pub fn discover_catalog_options_json(addons_json: &str, selected_type: &str) -> Option { + search_plan::discover_catalog_options_json(addons_json, selected_type) + } + + pub fn discover_sort_plan_json(request_json: &str) -> Option { + search_plan::discover_sort_plan_json(request_json) + } + + pub fn library_sort_plan_json(request_json: &str) -> Option { + search_plan::library_sort_plan_json(request_json) + } + + pub fn detail_series_lookup_id(raw_id: &str) -> String { + search_plan::detail_series_lookup_id(raw_id) + } + + pub fn detail_season_load_plan_json(request_json: &str) -> Option { + search_plan::detail_season_load_plan_json(request_json) + } + + pub fn player_backend_selection_json(request_json: &str) -> Option { + player_policy::player_backend_selection_json(request_json) + } + + pub fn torrent_fallback_file_policy_json(request_json: &str) -> Option { + player_policy::torrent_fallback_file_policy_json(request_json) + } + + pub fn player_buffer_targets_json(request_json: &str) -> Option { + player_policy::player_buffer_targets_json(request_json) + } + + pub fn player_retry_policy_json(request_json: &str) -> Option { + player_policy::player_retry_policy_json(request_json) + } + + pub fn player_source_sidebar_plan_json(request_json: &str) -> Option { + player_policy::player_source_sidebar_plan_json(request_json) + } + + pub fn calendar_content_plan_json(request_json: &str) -> Option { + calendar_plan::calendar_content_plan_json(request_json) + } + + pub fn calendar_season_candidates_json(request_json: &str) -> Option { + calendar_plan::calendar_season_candidates_json(request_json) + } + + pub fn calendar_widget_rows_json(request_json: &str) -> Option { + calendar_plan::calendar_widget_rows_json(request_json) + } + + pub fn calendar_notification_content_json(request_json: &str) -> Option { + calendar_plan::calendar_notification_content_json(request_json) + } + + pub fn calendar_release_detection_json(request_json: &str) -> Option { + calendar_plan::calendar_release_detection_json(request_json) + } + + pub fn repository_meta_detail_plan_json(request_json: &str) -> Option { + repository_flow::repository_meta_detail_plan_json(request_json) + } + + pub fn repository_season_videos_json(meta_detail_json: &str, season_number: i32) -> String { + repository_flow::repository_season_videos_json(meta_detail_json, season_number) + } + + pub fn manifest_fetch_decision_json(request_json: &str) -> Option { + repository_flow::manifest_fetch_decision_json(request_json) + } + + pub fn addon_resource_request_plan_json(request_json: &str) -> Option { + repository_flow::addon_resource_request_plan_json(request_json) + } + + pub fn resource_fetch_plan_json(request_json: &str) -> Option { + platform_plan::resource_fetch_plan_json(request_json) + } + + pub fn resource_parse_plan_json(request_json: &str) -> Option { + platform_plan::resource_parse_plan_json(request_json) + } + + pub fn playback_prepare_plan_json(request_json: &str) -> Option { + platform_plan::playback_prepare_plan_json(request_json) + } + + pub fn library_local_state_plan_json(request_json: &str) -> Option { + platform_plan::library_local_state_plan_json(request_json) + } + + pub fn preferences_schema_json() -> String { + platform_plan::preferences_schema_json() + } + + pub fn apply_preference_update_json(request_json: &str) -> Option { + platform_plan::apply_preference_update_json(request_json) + } + + pub fn addon_collection_mutation_plan_json(request_json: &str) -> Option { + platform_plan::addon_collection_mutation_plan_json(request_json) + } + + pub fn detail_episode_plan_json(request_json: &str) -> Option { + platform_plan::detail_episode_plan_json(request_json) + } + + pub fn addon_streams_with_provider_json(streams_json: &str, addon_name: &str) -> String { + repository_flow::addon_streams_with_provider_json(streams_json, addon_name) + } + + pub fn active_profile_plan_json(request_json: &str) -> Option { + profile_contract::active_profile_plan_json(request_json) + } + + pub fn token_merge_plan_json(request_json: &str) -> Option { + profile_contract::token_merge_plan_json(request_json) + } + + pub fn profile_default_seed_json(request_json: &str) -> Option { + profile_contract::profile_default_seed_json(request_json) + } + + pub fn profile_settings_migration_plan_json(request_json: &str) -> Option { + profile_contract::profile_settings_migration_plan_json(request_json) + } + + pub fn profile_avatar_default_json(request_json: &str) -> Option { + profile_contract::profile_avatar_default_json(request_json) + } + + pub fn watchlist_toggle_plan_json(request_json: &str) -> Option { + watchlist_plan::watchlist_toggle_plan_json(request_json) + } + + pub fn library_external_merge_plan_json(request_json: &str) -> Option { + watchlist_plan::library_external_merge_plan_json(request_json) + } + + pub fn library_collection_import_validation_json(request_json: &str) -> Option { + watchlist_plan::library_collection_import_validation_json(request_json) + } + + pub fn library_offline_grouping_json(request_json: &str) -> Option { + watchlist_plan::library_offline_grouping_json(request_json) + } + + pub fn playback_progress_merge_plan_json(request_json: &str) -> Option { + watchlist_plan::playback_progress_merge_plan_json(request_json) + } + + pub fn parse_episode_locator_json(raw: &str) -> Option { + let (base_id, season, episode) = content_identity::parse_episode_locator(raw)?; + serde_json::to_string(&json!({ + "baseId": base_id, + "season": season, + "episode": episode + })) + .ok() + } + + pub fn stream_request_ids_json( + content_type: &str, + id: &str, + detail_id: Option<&str>, + current_series_lookup_id: Option<&str>, + canonical_base_id: Option<&str>, + ) -> Option { + serde_json::to_string(&content_identity::stream_request_ids( + content_type, + id, + detail_id, + current_series_lookup_id, + canonical_base_id, + )) + .ok() + } + + pub fn playback_intro_lookup_content_id(id: &str) -> String { + content_identity::playback_intro_lookup_content_id(id) + } + + pub fn playback_stream_request_ids_json( + content_type: &str, + id: &str, + detail_id: Option<&str>, + ) -> Option { + content_identity::playback_stream_request_ids_json(content_type, id, detail_id) + } + + pub fn direct_playback_plan_json( + meta_json: &str, + detail_json: Option<&str>, + today_iso: &str, + ) -> Option { + content_identity::direct_playback_plan_json(meta_json, detail_json, today_iso) + } + + pub fn stream_discovery_episode_context_json( + content_type: &str, + request_id: &str, + detail_json: Option<&str>, + season_episodes_json: &str, + ) -> Option { + content_identity::stream_discovery_episode_context_json( + content_type, + request_id, + detail_json, + season_episodes_json, + ) + } + + pub fn stream_playback_info_json(stream_json: &str) -> Option { + stream_policy::stream_playback_info_json(stream_json) + } + + pub fn stream_request_headers_json(headers_json: &str) -> Option { + stream_policy::stream_request_headers_json(headers_json) + } + + pub fn stream_request_referer(url: &str) -> Option { + stream_policy::stream_request_referer(url) + } + + pub fn episode_text_matches(text: &str, season: i32, episode: i32) -> bool { + content_identity::text_matches_episode(text, season, episode) + } + + pub fn stream_matches_episode(video_id: &str, fields: &[String]) -> bool { + content_identity::stream_matches_episode(video_id, fields) + } + + pub fn select_stream_index( + streams_json: &str, + current_video_id: &str, + initial_stream_index: i32, + saved_url: Option<&str>, + saved_title: Option<&str>, + source_selection_mode: &str, + regex_pattern: Option<&str>, + preferred_binge_group: Option<&str>, + ) -> i32 { + stream_policy::select_stream_index( + streams_json, + current_video_id, + initial_stream_index, + saved_url, + saved_title, + source_selection_mode, + regex_pattern, + preferred_binge_group, + ) + } + + pub fn merge_continue_watching_duplicates_json(items_json: &str) -> Option { + content_identity::merge_continue_watching_duplicates_json(items_json) + } + + pub fn filter_discover_results_json( + items_json: &str, + year: Option<&str>, + rating: Option, + region: Option<&str>, + ) -> Option { + content_identity::filter_discover_results_json(items_json, year, rating, region) + } + + pub fn resolve_preferred_audio_language( + last_audio_language: Option<&str>, + preferred_audio_language: Option<&str>, + original_language: Option<&str>, + ) -> String { + stream_policy::resolve_preferred_audio_language( + last_audio_language, + preferred_audio_language, + original_language, + ) + } + + pub fn subtitle_language_matches( + label: &str, + language: Option<&str>, + preferred_language: &str, + ) -> bool { + stream_policy::subtitle_language_matches(label, language, preferred_language) + } + + pub fn find_preferred_subtitle_index( + tracks_json: &str, + last_subtitle_language: Option<&str>, + preferred_subtitle_language: Option<&str>, + secondary_subtitle_language: Option<&str>, + ) -> i32 { + stream_policy::find_preferred_subtitle_index( + tracks_json, + last_subtitle_language, + preferred_subtitle_language, + secondary_subtitle_language, + ) + } + + pub fn player_track_state_json(request_json: &str) -> Option { + stream_policy::player_track_state_json(request_json) + } + + pub fn torrent_runtime_info_json(request_json: &str) -> Option { + stream_policy::torrent_runtime_info_json(request_json) + } + + pub fn torrent_status_info_json(status_json: &str) -> Option { + stream_policy::torrent_status_info_json(status_json) + } + + pub fn stable_feed_part(value: &str) -> String { + content_identity::stable_feed_part(value) + } + + pub fn normalize_content_type(value: &str) -> Option<&'static str> { + content_identity::normalize_content_type(value) + } + + pub fn parse_extra_args_json(extra: &str) -> Option { + content_identity::parse_extra_args_json(extra) + } + + pub fn provider_search_terms(provider: &str) -> Vec { + content_identity::provider_search_terms(provider) + } + + pub fn effective_metadata_feed_selection_json( + selected_keys_json: &str, + available_keys_json: &str, + ) -> Option { + content_identity::effective_metadata_feed_selection_json( + selected_keys_json, + available_keys_json, + ) + } + + pub fn toggle_metadata_feed_json( + selected_keys_json: &str, + available_keys_json: &str, + key: &str, + ) -> Option { + content_identity::toggle_metadata_feed_json(selected_keys_json, available_keys_json, key) + } + + pub fn toggle_metadata_feed_limited_json( + selected_keys_json: &str, + available_keys_json: &str, + key: &str, + max_enabled: i32, + ) -> Option { + content_identity::toggle_metadata_feed_limited_json( + selected_keys_json, + available_keys_json, + key, + max_enabled, + ) + } + + pub fn set_metadata_feed_group_enabled_json( + selected_keys_json: &str, + available_keys_json: &str, + group_keys_json: &str, + enabled: bool, + ) -> Option { + content_identity::set_metadata_feed_group_enabled_json( + selected_keys_json, + available_keys_json, + group_keys_json, + enabled, + ) + } + + pub fn ordered_metadata_feed_keys(option_keys_json: &str, order_json: &str) -> Option { + content_identity::ordered_metadata_feed_keys(option_keys_json, order_json) + } + + pub fn move_metadata_feed_order_json( + option_keys_json: &str, + current_order_json: &str, + key: &str, + delta: i32, + ) -> Option { + content_identity::move_metadata_feed_order_json( + option_keys_json, + current_order_json, + key, + delta, + ) + } + + pub fn content_trakt_key(meta_json: &str) -> Option { + content_identity::content_trakt_key(meta_json) + } + + pub fn content_billboard_key(meta_json: &str) -> Option { + content_identity::content_billboard_key(meta_json) + } + + pub fn content_merge_keys_json(meta_json: &str) -> Option { + content_identity::content_keys_json(meta_json, false) + } + + pub fn content_watched_keys_json(meta_json: &str) -> Option { + content_identity::content_keys_json(meta_json, true) + } + + pub fn episode_filename_candidate(stream_json: &str, video_id: &str) -> Option { + content_identity::episode_filename_candidate(stream_json, video_id) + } + + pub fn stream_discovery_cache_key(request_json: &str) -> Option { + content_identity::stream_discovery_cache_key(request_json) + } + + pub fn stream_discovery_plan_json(request_json: &str) -> Option { + discovery_plan::stream_discovery_plan_json(request_json) + } + + pub fn stream_discovery_execution_policy_json(request_json: &str) -> Option { + discovery_plan::stream_discovery_execution_policy_json(request_json) + } + + pub fn stream_discovery_cache_prefix(content_type: &str, id: &str, language: &str) -> String { + discovery_plan::stream_discovery_cache_prefix(content_type, id, language) + } + + pub fn discover_catalog_cache_key(request_json: &str) -> Option { + content_identity::discover_catalog_cache_key(request_json) + } + + pub fn curate_home_items_json(category_json: &str) -> Option { + home_ranking::curate_home_items_json(category_json) + } + + pub fn home_overlap_ratio_json(first_json: &str, second_json: &str) -> Option { + home_ranking::home_overlap_ratio_json(first_json, second_json) + } + + pub fn home_personalization_score_json( + category_json: &str, + preferred_genres_json: &str, + preferred_types_json: &str, + priority_labels_json: &str, + ) -> Option { + home_ranking::home_personalization_score_json( + category_json, + preferred_genres_json, + preferred_types_json, + priority_labels_json, + ) + } + + pub fn prioritize_home_rows_json( + categories_json: &str, + preferred_order_labels_json: &str, + preferred_genres_json: &str, + preferred_types_json: &str, + priority_labels_json: &str, + ) -> Option { + home_ranking::home_prioritize_rows_json( + categories_json, + preferred_order_labels_json, + preferred_genres_json, + preferred_types_json, + priority_labels_json, + ) + } + + pub fn optimize_home_rows_json(request_json: &str) -> Option { + home_ranking::optimize_home_rows_json(request_json) + } + + pub fn billboard_score_candidate_json( + meta_json: &str, + days_since_release: Option, + ) -> Option { + home_ranking::billboard_score_candidate_json(meta_json, days_since_release) + } + + pub fn billboard_has_backdrop_candidate_json(meta_json: &str) -> bool { + home_ranking::has_billboard_backdrop_candidate_json(meta_json) + } + + pub fn billboard_visual_score_json(meta_json: &str) -> Option { + home_ranking::billboard_visual_score_json(meta_json) + } + + pub fn billboard_editorial_match_score_json(meta_json: &str, spec_json: &str) -> Option { + home_ranking::billboard_editorial_match_score_json(meta_json, spec_json) + } + + pub fn build_billboard_pool_json( + enriched_json: &str, + candidates_json: &str, + ) -> Option { + home_ranking::build_billboard_pool_json(enriched_json, candidates_json) + } + + pub fn normalize_home_catalog_items_json( + items_json: &str, + catalog_id: &str, + genre: Option<&str>, + today_iso: &str, + ) -> Option { + home_ranking::normalize_home_catalog_items_json(items_json, catalog_id, genre, today_iso) + } + + pub fn player_progress_percent(position_ms: i64, duration_ms: i64) -> f32 { + player_scrobble::progress_percent(position_ms, duration_ms) + } + + pub fn player_should_send_scrobble_start( + token: Option<&str>, + is_playing: bool, + has_scrobbled_start: bool, + progress: f32, + ) -> bool { + player_scrobble::should_send_start(token, is_playing, has_scrobbled_start, progress) + } + + pub fn player_should_mark_scrobble_stopped(has_scrobbled_stop: bool, progress: f32) -> bool { + player_scrobble::should_mark_stopped(has_scrobbled_stop, progress) + } + + pub fn player_should_queue_scrobble_pause( + token: Option<&str>, + was_play_when_ready: bool, + has_scrobbled_start: bool, + has_scrobbled_stop: bool, + ) -> bool { + player_scrobble::should_queue_pause( + token, + was_play_when_ready, + has_scrobbled_start, + has_scrobbled_stop, + ) + } + + pub fn player_should_enqueue_durable_scrobble( + action: &str, + token: Option<&str>, + progress: f32, + ) -> bool { + player_scrobble::should_enqueue_durable(action, token, progress) + } + + pub fn player_should_save_periodic_progress( + is_playing: bool, + now_ms: i64, + last_saved_at_ms: i64, + ) -> bool { + player_scrobble::should_save_periodic_progress(is_playing, now_ms, last_saved_at_ms) + } + + pub fn player_should_save_on_dispose(position_ms: i64) -> bool { + player_scrobble::should_save_on_dispose(position_ms) + } + + pub fn safe_player_buffer_cache_mb(value: Option) -> i32 { + profile_prefs::safe_player_buffer_cache_mb(value) + } + + pub fn safe_dolby_vision_fallback_mode( + mode: Option<&str>, + legacy_dv7_fallback: Option, + legacy_dv7_to_dv8_fallback: Option, + ) -> &'static str { + profile_prefs::safe_dolby_vision_fallback_mode( + mode, + legacy_dv7_fallback, + legacy_dv7_to_dv8_fallback, + ) + } + + pub fn safe_stream_source_selection_mode(mode: Option<&str>) -> &'static str { + profile_prefs::safe_stream_source_selection_mode(mode) + } + + pub fn profile_safe_prefs_json(profile_json: &str) -> Option { + profile_prefs::profile_safe_prefs_json(profile_json) + } + + pub fn player_flow_dispatch_json(state_json: &str, action_json: &str) -> Option { + player_flow::player_flow_dispatch_json(state_json, action_json) + } + + pub fn trakt_has_client(api_key: &str) -> bool { + external_sync::trakt_has_client(api_key) + } + + pub fn trakt_bearer(token: &str) -> String { + external_sync::trakt_bearer(token) + } + + pub fn trakt_scrobble_url(action: &str) -> String { + external_sync::trakt_scrobble_url(action) + } + + pub fn trakt_playback_url(content_type: Option<&str>) -> String { + external_sync::trakt_playback_url(content_type) + } + + pub fn trakt_token_expires_at(created_at_seconds: i64, expires_in_seconds: i64) -> i64 { + external_sync::trakt_token_expires_at(created_at_seconds, expires_in_seconds) + } + + pub fn trakt_oauth_error_code(body: &str) -> Option { + external_sync::trakt_oauth_error_code(body) + } + + pub fn trakt_content_id_from_ids_json(ids_json: &str) -> Option { + external_sync::trakt_content_id_from_ids_json(ids_json) + } + + pub fn trakt_ids_from_content_id_json(raw_id: &str) -> Option { + external_sync::trakt_ids_from_content_id_json(raw_id) + } + + pub fn trakt_episode_locator_json(video_id: &str) -> Option { + external_sync::trakt_episode_locator_json(video_id) + } + + pub fn trakt_show_id_from_episode_id(video_id: &str) -> String { + external_sync::trakt_show_id_from_episode_id(video_id) + } + + pub fn trakt_scrobble_media_id( + parent_id: &str, + video_id: Option<&str>, + media_type: &str, + ) -> String { + external_sync::trakt_scrobble_media_id(parent_id, video_id, media_type) + } + + pub fn trakt_history_request_json(meta_json: &str, episodes_json: &str) -> Option { + external_sync::trakt_history_request_json(meta_json, episodes_json) + } + + pub fn playback_progress_item_json( + meta_json: &str, + time_offset: i64, + duration: i64, + now_utc: &str, + ) -> Option { + library_state::playback_progress_item_json(meta_json, time_offset, duration, now_utc) + } + + pub fn clear_playback_progress_item_json(meta_json: &str) -> Option { + library_state::clear_playback_progress_item_json(meta_json) + } + + pub fn watched_state_items_json( + meta_json: &str, + episodes_json: &str, + watched: bool, + watched_at: Option<&str>, + ) -> Option { + library_state::watched_state_items_json(meta_json, episodes_json, watched, watched_at) + } + + pub fn library_continue_watching_items_json(items_json: &str) -> Option { + library_state::library_continue_watching_items_json(items_json) + } + + pub fn filter_home_continue_watching_json( + items_json: &str, + trakt_watched_json: &str, + ) -> Option { + library_state::filter_home_continue_watching_json(items_json, trakt_watched_json) + } + + pub fn watched_video_ids_json(items_json: &str, imdb_id: &str) -> Option { + library_state::watched_video_ids_json(items_json, imdb_id) + } + + pub fn offline_download_plan_json(request_json: &str) -> Option { + offline_download::offline_download_plan_json(request_json) + } + + // ── content_identity extras ─────────────────────────────────────────────── + + pub fn parse_video_id_json(id: &str) -> String { + content_identity::parse_video_id_json(id) + } + + pub fn build_trakt_ids_json(video_id: &str) -> Option { + content_identity::build_trakt_ids_json(video_id) + } + + // ── calendar extras ─────────────────────────────────────────────────────── + + pub fn calendar_items_from_meta_json(meta_json: &str, month_prefix: &str) -> Option { + calendar_plan::calendar_items_from_meta_json(meta_json, month_prefix) + } + + pub fn calendar_item_matches_month_json(item_json: &str, month_prefix: &str) -> bool { + calendar_plan::calendar_item_matches_month_json(item_json, month_prefix) + } + + // ── external_sync: Trakt high-level ────────────────────────────────────── + + pub fn trakt_playback_items_to_library_json(items_json: &str) -> Option { + external_sync::trakt_playback_items_to_library_json(items_json) + } + + pub fn trakt_watchlist_to_items_json(movies_json: &str, shows_json: &str) -> Option { + external_sync::trakt_watchlist_to_items_json(movies_json, shows_json) + } + + pub fn trakt_watched_to_ids_json(movies_json: &str, shows_json: &str) -> Option { + external_sync::trakt_watched_to_ids_json(movies_json, shows_json) + } + + pub fn merge_external_watchlist_json(local_json: &str, external_json: &str) -> String { + external_sync::merge_external_watchlist_json(local_json, external_json) + } + + pub fn merge_external_watched_json(local_json: &str, external_json: &str) -> String { + external_sync::merge_external_watched_json(local_json, external_json) + } + + pub fn merge_continue_watching_lists_json( + local_json: &str, + external_json: &str, + progress_json: &str, + ) -> Option { + external_sync::merge_continue_watching_lists_json(local_json, external_json, progress_json) + } + + // ── external_sync: Simkl ───────────────────────────────────────────────── + + pub fn simkl_watching_to_items_json(shows_json: &str, movies_json: &str) -> Option { + external_sync::simkl_watching_to_items_json(shows_json, movies_json) + } + + pub fn simkl_watchlist_to_items_json(shows_json: &str, movies_json: &str) -> Option { + external_sync::simkl_watchlist_to_items_json(shows_json, movies_json) + } + + pub fn simkl_watched_to_ids_json(shows_json: &str, movies_json: &str) -> Option { + external_sync::simkl_watched_to_ids_json(shows_json, movies_json) + } + + // ── library_state extras ───────────────────────────────────────────────── + + pub fn normalize_library_document_json(json: &str) -> String { + library_state::normalize_library_document_json(json) + } + + pub fn is_up_next_continue_watching_item_json(item_json: &str) -> bool { + library_state::is_up_next_continue_watching_item_json(item_json) + } + + pub fn build_continue_watching_from_progress_json(progress_json: &str) -> Option { + library_state::build_continue_watching_from_progress_json(progress_json) + } + + pub fn remember_last_watched_episodes_json(lib_json: &str, watched_ids_json: &str) -> String { + library_state::remember_last_watched_episodes_json(lib_json, watched_ids_json) + } + + pub fn compute_continue_watching_badges_json( + candidates_json: &str, + videos_by_series_json: &str, + last_watched_json: &str, + now_ms: i64, + ) -> Option { + library_state::compute_continue_watching_badges_json( + candidates_json, + videos_by_series_json, + last_watched_json, + now_ms, + ) + } + + // ── tmdb_plan ──────────────────────────────────────────────────────────── + + pub fn tmdb_content_type(content_type: &str) -> &str { + tmdb_plan::tmdb_content_type(content_type) + } + + pub fn tmdb_language(language: &str) -> String { + tmdb_plan::tmdb_language(language) + } + + pub fn tmdb_image_url(path: Option<&str>, size: &str) -> Option { + tmdb_plan::tmdb_image_url(path, size) + } + + pub fn tmdb_meta_to_meta_json(item_json: &str, requested_type: &str, language: &str) -> Option { + tmdb_plan::tmdb_meta_to_meta_json(item_json, requested_type, language) + } + + pub fn tmdb_video_to_trailer_json(video_json: &str) -> Option { + tmdb_plan::tmdb_video_to_trailer_json(video_json) + } + + pub fn tmdb_bulk_metas_to_metas_json( + items_json: &str, + requested_type: &str, + language: &str, + ) -> Option { + tmdb_plan::tmdb_bulk_metas_to_metas_json(items_json, requested_type, language) + } + + pub fn tmdb_bulk_videos_to_trailers_json(items_json: &str) -> Option { + tmdb_plan::tmdb_bulk_videos_to_trailers_json(items_json) + } + + pub fn tmdb_resolve_id_hint(content_id: &str) -> (String, bool) { + tmdb_plan::tmdb_resolve_id_hint(content_id) + } + + // ── intro_segments ──────────────────────────────────────────────────────── + + pub fn parse_intro_db_segments_json(data_json: &str) -> Option { + intro_segments::parse_intro_db_segments_json(data_json) + } + + pub fn parse_aniskip_results_json(results_json: &str) -> Option { + intro_segments::parse_aniskip_results_json(results_json) + } + + pub fn unique_intro_segments_json(segments_a_json: &str, segments_b_json: &str) -> Option { + intro_segments::unique_intro_segments_json(segments_a_json, segments_b_json) + } + + pub fn merge_intro_segments_json(sources_json: &str) -> Option { + intro_segments::merge_intro_segments_json(sources_json) + } +} + +#[cfg(test)] +mod tests { + use super::FluxaCore; + + #[test] + fn public_core_api_builds_stremio_resource_urls() { + let url = FluxaCore::build_resource_url( + "https://addon.example/root/manifest.json", + "stream", + "series", + "tt123:1:2", + Some(r#"{"search":"one two"}"#), + ); + + assert!(url.starts_with("https://addon.example/root/stream/series/tt123%3A1%3A2/")); + assert!(url.ends_with(".json")); + assert!(url.contains("search=one%20two")); + } + + #[test] + fn public_core_api_drives_app_state_without_jni() { + let handle = FluxaCore::create_app_core_state(r#"{"player":{"currentStreamIndex":3}}"#); + assert!(handle > 0); + + let snapshot = FluxaCore::app_core_dispatch_json( + handle, + r#"{"type":"playerResetForEpisode","videoId":"tt123:1:2"}"#, + ) + .expect("state snapshot"); + + assert!(snapshot.contains(r#""currentVideoId":"tt123:1:2""#)); + assert!(snapshot.contains(r#""currentStreamIndex":0"#)); + assert!(FluxaCore::destroy_app_core_state(handle)); + } + + #[test] + fn public_core_api_owns_player_scrobble_decisions() { + assert_eq!(FluxaCore::player_progress_percent(5_000, 10_000), 50.0); + assert!(FluxaCore::player_should_send_scrobble_start( + Some("token"), + true, + false, + 0.3, + )); + assert!(FluxaCore::player_should_mark_scrobble_stopped(false, 80.0)); + assert!(FluxaCore::player_should_queue_scrobble_pause( + Some("token"), + true, + true, + false, + )); + assert!(!FluxaCore::player_should_enqueue_durable_scrobble( + "pause", + Some("token"), + 0.5, + )); + assert!(FluxaCore::player_should_save_periodic_progress( + true, 30_001, 0 + )); + assert!(FluxaCore::player_should_save_on_dispose(5_001)); + } + + + #[test] + fn public_core_api_normalizes_profile_playback_preferences() { + assert_eq!(FluxaCore::safe_player_buffer_cache_mb(Some(50)), 100); + assert_eq!( + FluxaCore::safe_dolby_vision_fallback_mode(None, Some(false), Some(true)), + "dv8", + ); + assert_eq!( + FluxaCore::safe_stream_source_selection_mode(Some("unknown")), + "manual", + ); + } + + #[test] + fn public_core_api_drives_player_stream_loading_effects() { + let result = FluxaCore::player_flow_dispatch_json( + "{}", + r#"{"type":"loadStreamsRequested","contentType":"movie","id":"tt1","currentVideoId":"tt1","initialVideoId":null,"initialStreams":[],"initialStreamIndex":0}"#, + ) + .expect("flow result"); + + assert!(result.contains(r#""type":"loadStreams""#)); + assert!(result.contains(r#""isBuffering":true"#)); + } +} diff --git a/src/core_contract.rs b/src/core_contract.rs new file mode 100644 index 0000000..786a308 --- /dev/null +++ b/src/core_contract.rs @@ -0,0 +1,194 @@ +use crate::headless_engine; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// Stable domain-brain contract for platform shells. +/// +/// `CoreBrainSession` wraps the existing headless engine while the app migrates +/// from JSON-only calls to typed UniFFI contracts. Dynamic provider payloads +/// stay as `serde_json::Value`; action/effect/state ownership is explicit. +#[derive(Debug)] +pub struct CoreBrainSession { + handle: u64, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CoreAction { + #[serde(rename = "type")] + pub action_type: String, + #[serde(default)] + pub payload: Value, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CoreState { + #[serde(default)] + pub value: Value, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CoreEffect { + pub id: String, + #[serde(rename = "type")] + pub effect_type: String, + #[serde(default)] + pub generation: u64, + #[serde(default)] + pub payload: Value, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CoreEffectResult { + pub effect_id: String, + pub status: String, + #[serde(default)] + pub value: Value, + #[serde(default)] + pub error: Value, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CoreDispatchResult { + pub state: CoreState, + #[serde(default)] + pub effects: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct CoreCapabilitySet { + pub http: bool, + pub storage: bool, + pub auth: bool, + pub player: bool, + pub plugins: bool, + pub torrent: bool, + pub local_stream: bool, + pub notifications: bool, +} + +impl CoreCapabilitySet { + pub fn android_default() -> Self { + Self { + http: true, + storage: true, + auth: true, + player: true, + plugins: true, + torrent: false, + local_stream: false, + notifications: true, + } + } + + pub fn portable_minimum() -> Self { + Self { + http: true, + storage: true, + auth: true, + player: true, + plugins: false, + torrent: false, + local_stream: false, + notifications: false, + } + } +} + +impl CoreBrainSession { + pub fn new(initial_state: Value) -> Self { + let initial_json = + serde_json::to_string(&initial_state).unwrap_or_else(|_| "{}".to_string()); + Self { + handle: headless_engine::create_headless_engine(&initial_json), + } + } + + pub fn snapshot(&self) -> Option { + headless_engine::headless_engine_snapshot_json(self.handle) + .and_then(|json| serde_json::from_str::(&json).ok()) + .map(|value| CoreState { value }) + } + + pub fn dispatch_json(&self, action_json: &str) -> Option { + headless_engine::headless_engine_dispatch_json(self.handle, action_json) + .and_then(|json| parse_dispatch_result(&json)) + } + + pub fn complete_json(&self, result_json: &str) -> Option { + headless_engine::headless_engine_complete_effect_json(self.handle, result_json) + .and_then(|json| parse_dispatch_result(&json)) + } +} + +impl Drop for CoreBrainSession { + fn drop(&mut self) { + headless_engine::destroy_headless_engine(self.handle); + } +} + +pub fn core_capabilities_json(portable: bool) -> String { + let capabilities = if portable { + CoreCapabilitySet::portable_minimum() + } else { + CoreCapabilitySet::android_default() + }; + serde_json::to_string(&capabilities).unwrap_or_else(|_| "{}".to_string()) +} + +fn parse_dispatch_result(json: &str) -> Option { + let raw = serde_json::from_str::(json).ok()?; + let state = raw.get("state").cloned().unwrap_or(Value::Null); + let effects = raw + .get("effects") + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .filter_map(|item| serde_json::from_value::(item.clone()).ok()) + .collect::>() + }) + .unwrap_or_default(); + Some(CoreDispatchResult { + state: CoreState { value: state }, + effects, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn typed_session_wraps_headless_state_and_effects() { + let session = CoreBrainSession::new(json!({"profile":{"activeProfileId":"p1"}})); + let result = session + .dispatch_json(r#"{"type":"searchRequested","query":"matrix","language":"en"}"#) + .expect("dispatch result"); + + assert_eq!(result.effects.len(), 1); + assert_eq!(result.effects[0].effect_type, "runSearch"); + assert_eq!(result.effects[0].payload["profileId"], "p1"); + assert!(session.snapshot().is_some()); + } + + #[test] + fn capability_sets_make_native_and_portable_differences_explicit() { + let native = + serde_json::from_str::(&core_capabilities_json(false)).unwrap(); + let portable = + serde_json::from_str::(&core_capabilities_json(true)).unwrap(); + + assert!(!native.torrent); + assert!(!native.local_stream); + assert!(!portable.torrent); + assert!(!portable.local_stream); + assert!(!portable.plugins); + } +} diff --git a/src/data_policy.rs b/src/data_policy.rs new file mode 100644 index 0000000..30d5a38 --- /dev/null +++ b/src/data_policy.rs @@ -0,0 +1,203 @@ +use serde::{Deserialize, Serialize}; +use serde_json::json; + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct CacheEntryPolicyInput { + key: String, + stored_at_millis: i64, + ttl_millis: i64, + now_millis: i64, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct CacheTrimPolicyInput { + #[serde(default)] + entries: Vec, + max_entries: i64, + now_millis: i64, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct CacheTrimEntry { + key: String, + expires_at_millis: i64, + stored_at_millis: i64, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct DataFailurePolicyInput { + operation: String, + kind: String, + message: Option, + throwable_class: Option, + reason: Option, + status_code: Option, +} + +pub(crate) fn cache_entry_policy_json(request_json: &str) -> Option { + let request: CacheEntryPolicyInput = serde_json::from_str(request_json).ok()?; + let ttl = request.ttl_millis.max(1); + let expires_at_millis = saturating_add(request.stored_at_millis, ttl); + serde_json::to_string(&json!({ + "key": request.key, + "storedAtMillis": request.stored_at_millis, + "expiresAtMillis": expires_at_millis, + "isExpired": expires_at_millis <= request.now_millis + })) + .ok() +} + +pub(crate) fn cache_trim_policy_json(request_json: &str) -> Option { + let request: CacheTrimPolicyInput = serde_json::from_str(request_json).ok()?; + let max_entries = request.max_entries.max(1) as usize; + let expired_keys = request + .entries + .iter() + .filter(|entry| entry.expires_at_millis <= request.now_millis) + .map(|entry| entry.key.clone()) + .collect::>(); + let mut live_entries = request + .entries + .into_iter() + .filter(|entry| entry.expires_at_millis > request.now_millis) + .collect::>(); + live_entries.sort_by(|a, b| { + a.stored_at_millis + .cmp(&b.stored_at_millis) + .then_with(|| a.key.cmp(&b.key)) + }); + let overflow = live_entries.len().saturating_sub(max_entries); + let evicted_keys = live_entries + .iter() + .take(overflow) + .map(|entry| entry.key.clone()) + .collect::>(); + + serde_json::to_string(&json!({ + "expiredKeys": expired_keys, + "evictedKeys": evicted_keys + })) + .ok() +} + +pub(crate) fn data_failure_policy_json(request_json: &str) -> Option { + let request: DataFailurePolicyInput = serde_json::from_str(request_json).ok()?; + let kind = normalize_failure_kind(&request.kind); + let message = match kind { + "auth_unavailable" => "auth_unavailable".to_string(), + "unsupported" => request + .reason + .or(request.message) + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| "unsupported".to_string()), + "network_error" => request + .message + .or(request.throwable_class) + .filter(|value| !value.trim().is_empty()) + .or_else(|| request.status_code.map(|code| format!("http_{code}"))) + .unwrap_or_else(|| "network_error".to_string()), + "parse_error" => request + .message + .or(request.throwable_class) + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| "parse_error".to_string()), + _ => request + .message + .or(request.throwable_class) + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| "unknown".to_string()), + }; + let retryable = matches!(kind, "network_error") + && !matches!(request.status_code, Some(400 | 401 | 403 | 404)); + let stale_fallback_allowed = matches!(kind, "network_error" | "parse_error"); + + serde_json::to_string(&json!({ + "operation": request.operation, + "kind": kind, + "message": message, + "retryable": retryable, + "staleFallbackAllowed": stale_fallback_allowed + })) + .ok() +} + +fn normalize_failure_kind(kind: &str) -> &'static str { + match kind.trim() { + "auth" | "auth_unavailable" | "AuthUnavailable" => "auth_unavailable", + "network" | "network_error" | "NetworkError" => "network_error", + "parse" | "parse_error" | "ParseError" => "parse_error", + "unsupported" | "Unsupported" => "unsupported", + _ => "unknown", + } +} + +fn saturating_add(left: i64, right: i64) -> i64 { + left.checked_add(right).unwrap_or(i64::MAX) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::Value; + + #[test] + fn cache_entry_policy_clamps_ttl_and_reports_expiry() { + let value: Value = serde_json::from_str( + &cache_entry_policy_json( + r#"{"key":"k","storedAtMillis":1000,"ttlMillis":0,"nowMillis":1001}"#, + ) + .expect("policy"), + ) + .expect("json"); + + assert_eq!(value["expiresAtMillis"].as_i64(), Some(1001)); + assert_eq!(value["isExpired"].as_bool(), Some(true)); + } + + #[test] + fn cache_trim_policy_removes_expired_before_oldest_overflow() { + let value: Value = serde_json::from_str( + &cache_trim_policy_json( + r#"{ + "maxEntries":2, + "nowMillis":50, + "entries":[ + {"key":"expired","expiresAtMillis":49,"storedAtMillis":1}, + {"key":"old","expiresAtMillis":100,"storedAtMillis":2}, + {"key":"newer","expiresAtMillis":100,"storedAtMillis":3}, + {"key":"newest","expiresAtMillis":100,"storedAtMillis":4} + ] + }"#, + ) + .expect("policy"), + ) + .expect("json"); + + assert_eq!(value["expiredKeys"][0].as_str(), Some("expired")); + assert_eq!(value["evictedKeys"][0].as_str(), Some("old")); + } + + #[test] + fn data_failure_policy_keeps_stable_messages_and_retry_flags() { + let auth: Value = serde_json::from_str( + &data_failure_policy_json(r#"{"operation":"trakt","kind":"AuthUnavailable"}"#) + .expect("policy"), + ) + .expect("json"); + let network_404: Value = serde_json::from_str( + &data_failure_policy_json( + r#"{"operation":"catalog","kind":"NetworkError","statusCode":404}"#, + ) + .expect("policy"), + ) + .expect("json"); + + assert_eq!(auth["message"].as_str(), Some("auth_unavailable")); + assert_eq!(network_404["retryable"].as_bool(), Some(false)); + assert_eq!(network_404["staleFallbackAllowed"].as_bool(), Some(true)); + } +} diff --git a/src/discovery_plan.rs b/src/discovery_plan.rs new file mode 100644 index 0000000..d1eeffe --- /dev/null +++ b/src/discovery_plan.rs @@ -0,0 +1,315 @@ +use crate::addon_protocol; +use crate::content_identity; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct StreamDiscoveryPlanRequest { + #[serde(rename = "type")] + content_type: String, + id: String, + language: String, + #[serde(default)] + prefer_fast_start: bool, + #[serde(default)] + addon_request_timeout_ms: i64, + #[serde(default)] + fast_addon_request_timeout_ms: i64, + #[serde(default)] + cloudstream_timeout_ms: i64, + #[serde(default)] + addons: Vec, + #[serde(default)] + cs3_plugin_names: Vec, + cs3_search_query: Option, + cs3_original_name: Option, + cs3_year: Option, + #[serde(default)] + max_concurrent_addon_requests: i64, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct StreamDiscoveryAddon { + transport_url: String, + manifest: Value, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct StreamDiscoveryPlan { + cache_key: String, + addon_requests: Vec, + cloudstream_request: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct StreamDiscoveryExecutionPolicy { + cache_key: String, + cache_lookup_prefix: String, + max_concurrent_addon_requests: i64, + cache_write_minimum_result_count: i64, + emit_cached_result: bool, + emit_partial_non_empty_results: bool, + addon_requests: Vec, + cloudstream_request: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct StreamAddonRequest { + transport_url: String, + addon_name: String, + #[serde(rename = "type")] + content_type: String, + id: String, + timeout_ms: i64, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct CloudstreamRequest { + id: String, + title: String, + year: Option, + #[serde(rename = "type")] + content_type: String, + season: Option, + episode: Option, + original_name: Option, + timeout_ms: i64, +} + +pub(crate) fn stream_discovery_plan_json(request_json: &str) -> Option { + let request = serde_json::from_str::(request_json).ok()?; + let plan = build_stream_discovery_plan(request)?; + serde_json::to_string(&StreamDiscoveryPlan { + cache_key: plan.cache_key, + addon_requests: plan.addon_requests, + cloudstream_request: plan.cloudstream_request, + }) + .ok() +} + +pub(crate) fn stream_discovery_cache_prefix( + content_type: &str, + id: &str, + language: &str, +) -> String { + format!("{content_type}|{id}|{language}") +} + +pub(crate) fn stream_discovery_execution_policy_json(request_json: &str) -> Option { + let request = serde_json::from_str::(request_json).ok()?; + let requested_concurrency = request.max_concurrent_addon_requests; + let cache_lookup_prefix = + stream_discovery_cache_prefix(&request.content_type, &request.id, &request.language); + let plan = build_stream_discovery_plan(request)?; + let max_concurrent_addon_requests = if requested_concurrency <= 0 { + (plan.addon_requests.len() as i64).clamp(1, 32) + } else { + requested_concurrency.clamp(1, 64) + }; + serde_json::to_string(&StreamDiscoveryExecutionPolicy { + cache_key: plan.cache_key, + cache_lookup_prefix, + max_concurrent_addon_requests, + cache_write_minimum_result_count: 1, + emit_cached_result: true, + emit_partial_non_empty_results: true, + addon_requests: plan.addon_requests, + cloudstream_request: plan.cloudstream_request, + }) + .ok() +} + +fn build_stream_discovery_plan(request: StreamDiscoveryPlanRequest) -> Option { + let addon_signatures = request + .addons + .iter() + .map(|addon| { + let id = addon + .manifest + .get("id") + .and_then(Value::as_str) + .unwrap_or_default(); + format!("{id}@{}", addon.transport_url) + }) + .collect::>(); + let cache_key = content_identity::stream_discovery_cache_key( + &serde_json::json!({ + "type": request.content_type, + "id": request.id, + "language": request.language, + "cs3SearchQuery": request.cs3_search_query, + "cs3Year": request.cs3_year, + "cs3OriginalName": request.cs3_original_name, + "addonSignatures": addon_signatures, + "cs3PluginNames": request.cs3_plugin_names, + }) + .to_string(), + )?; + let timeout_ms = if request.prefer_fast_start { + request.fast_addon_request_timeout_ms + } else { + request.addon_request_timeout_ms + }; + let addon_requests = request + .addons + .iter() + .filter(|addon| { + addon_protocol::supports_resource( + &addon.manifest.to_string(), + "stream", + Some(&request.content_type), + Some(&request.id), + ) + }) + .map(|addon| StreamAddonRequest { + transport_url: addon.transport_url.clone(), + addon_name: addon + .manifest + .get("name") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + content_type: request.content_type.clone(), + id: request.id.clone(), + timeout_ms, + }) + .collect::>(); + let episode_locator = content_identity::parse_episode_locator(&request.id); + let cloudstream_request = if request.cs3_plugin_names.is_empty() { + None + } else { + request + .cs3_search_query + .as_ref() + .map(|title| CloudstreamRequest { + id: request.id.clone(), + title: title.clone(), + year: request.cs3_year, + content_type: request.content_type.clone(), + season: episode_locator.as_ref().map(|(_, season, _)| *season), + episode: episode_locator.as_ref().map(|(_, _, episode)| *episode), + original_name: request.cs3_original_name.clone(), + timeout_ms: request.cloudstream_timeout_ms, + }) + }; + Some(StreamDiscoveryPlan { + cache_key, + addon_requests, + cloudstream_request, + }) +} + +#[cfg(test)] +mod tests { + use super::{ + stream_discovery_cache_prefix, stream_discovery_execution_policy_json, + stream_discovery_plan_json, + }; + use serde_json::Value; + + #[test] + fn stream_discovery_plan_filters_by_manifest_without_reordering_addons() { + let plan = stream_discovery_plan_json( + r#"{ + "type":"series", + "id":"tt1:2:7", + "language":"en", + "preferFastStart":true, + "addonRequestTimeoutMs":1000, + "fastAddonRequestTimeoutMs":3000, + "cloudstreamTimeoutMs":5000, + "cs3PluginNames":["cs"], + "cs3SearchQuery":"Show", + "cs3OriginalName":"Original Show", + "cs3Year":2024, + "addons":[ + {"transportUrl":"https://a/manifest.json","manifest":{"id":"a","name":"A","resources":["stream"],"types":["movie"]}}, + {"transportUrl":"https://b/manifest.json","manifest":{"id":"b","name":"B","resources":["stream"],"types":["series"]}}, + {"transportUrl":"https://c/manifest.json","manifest":{"id":"c","name":"C","resources":[{"name":"stream","types":["series"]}],"types":["series"]}} + ] + }"#, + ) + .expect("plan"); + let plan: Value = serde_json::from_str(&plan).expect("plan json"); + let addon_requests = plan["addonRequests"].as_array().expect("addon requests"); + + assert_eq!(addon_requests.len(), 2); + assert_eq!(addon_requests[0]["addonName"].as_str(), Some("B")); + assert_eq!(addon_requests[1]["addonName"].as_str(), Some("C")); + assert_eq!(addon_requests[0]["timeoutMs"].as_i64(), Some(3000)); + assert_eq!(plan["cloudstreamRequest"]["season"].as_i64(), Some(2)); + assert_eq!(plan["cloudstreamRequest"]["episode"].as_i64(), Some(7)); + } + + #[test] + fn stream_discovery_execution_policy_owns_cache_and_progress_rules() { + let policy = stream_discovery_execution_policy_json( + r#"{ + "type":"movie", + "id":"tt1", + "language":"tr", + "addonRequestTimeoutMs":1000, + "fastAddonRequestTimeoutMs":3000, + "cloudstreamTimeoutMs":5000, + "maxConcurrentAddonRequests":64, + "addons":[ + {"transportUrl":"https://a/manifest.json","manifest":{"id":"a","name":"A","resources":["stream"],"types":["movie"]}}, + {"transportUrl":"https://b/manifest.json","manifest":{"id":"b","name":"B","resources":["stream"],"types":["movie"]}} + ], + "cs3PluginNames":["cs"], + "cs3SearchQuery":"Movie" + }"#, + ) + .expect("policy"); + let policy: Value = serde_json::from_str(&policy).expect("policy json"); + let addon_requests = policy["addonRequests"].as_array().expect("addon requests"); + + assert_eq!(policy["cacheLookupPrefix"].as_str(), Some("movie|tt1|tr")); + assert_eq!(policy["maxConcurrentAddonRequests"].as_i64(), Some(64)); + assert_eq!(policy["cacheWriteMinimumResultCount"].as_i64(), Some(1)); + assert_eq!(policy["emitCachedResult"].as_bool(), Some(true)); + assert_eq!(policy["emitPartialNonEmptyResults"].as_bool(), Some(true)); + assert_eq!(addon_requests[0]["addonName"].as_str(), Some("A")); + assert_eq!(addon_requests[1]["addonName"].as_str(), Some("B")); + assert!(policy["cloudstreamRequest"].is_object()); + } + + #[test] + fn stream_discovery_execution_policy_auto_concurrency_uses_addon_count() { + let policy = stream_discovery_execution_policy_json( + r#"{ + "type":"movie", + "id":"tt2", + "language":"en", + "addonRequestTimeoutMs":1000, + "fastAddonRequestTimeoutMs":3000, + "cloudstreamTimeoutMs":5000, + "maxConcurrentAddonRequests":0, + "addons":[ + {"transportUrl":"https://a/manifest.json","manifest":{"id":"a","name":"A","resources":["stream"],"types":["movie"]}}, + {"transportUrl":"https://b/manifest.json","manifest":{"id":"b","name":"B","resources":["stream"],"types":["movie"]}}, + {"transportUrl":"https://c/manifest.json","manifest":{"id":"c","name":"C","resources":["stream"],"types":["movie"]}} + ], + "cs3PluginNames":[] + }"#, + ) + .expect("policy"); + let policy: Value = serde_json::from_str(&policy).expect("policy json"); + // 3 addons, auto mode → concurrency = min(3, 8) = 3 + assert_eq!(policy["maxConcurrentAddonRequests"].as_i64(), Some(3)); + } + + #[test] + fn stream_discovery_cache_prefix_is_owned_by_core() { + assert_eq!( + stream_discovery_cache_prefix("series", "tt1:1:2", "en"), + "series|tt1:1:2|en" + ); + } +} diff --git a/src/dolby_vision_rpu.rs b/src/dolby_vision_rpu.rs new file mode 100644 index 0000000..222fcc4 --- /dev/null +++ b/src/dolby_vision_rpu.rs @@ -0,0 +1,169 @@ +use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; +use ::dolby_vision::rpu::dovi_rpu::DoviRpu; +use serde::{Deserialize, Serialize}; + +#[derive(Deserialize)] +struct RpuRequest { + #[serde(default)] + rpu_hex: String, + #[serde(default)] + rpu_base64: String, + #[serde(default = "default_mode")] + mode: u8, +} + +#[derive(Serialize)] +struct RpuInfo { + ok: bool, + profile: Option, + el_type: Option, + error: Option, +} + +#[derive(Serialize)] +struct RpuConvertResult { + ok: bool, + profile_before: Option, + profile_after: Option, + el_type_before: Option, + el_type_after: Option, + rpu_hex: Option, + rpu_base64: Option, + error: Option, +} + +fn default_mode() -> u8 { + 2 +} + +pub fn dolby_vision_rpu_info_json(input: &str) -> Option { + let request = serde_json::from_str::(input).ok()?; + let bytes = request.rpu_bytes().map_err(|error| error.to_string()); + let result = match bytes { + Ok(bytes) => match DoviRpu::parse_unspec62_nalu(&bytes) { + Ok(rpu) => RpuInfo { + ok: true, + profile: Some(rpu.dovi_profile), + el_type: rpu.el_type.as_ref().map(|value| format!("{value:?}")), + error: None, + }, + Err(error) => RpuInfo { + ok: false, + profile: None, + el_type: None, + error: Some(error.to_string()), + }, + }, + Err(error) => RpuInfo { + ok: false, + profile: None, + el_type: None, + error: Some(error), + }, + }; + serde_json::to_string(&result).ok() +} + +pub fn dolby_vision_convert_rpu_json(input: &str) -> Option { + let request = serde_json::from_str::(input).ok()?; + let bytes = request.rpu_bytes().map_err(|error| error.to_string()); + let result = match bytes { + Ok(bytes) => match DoviRpu::parse_unspec62_nalu(&bytes) { + Ok(mut rpu) => { + let profile_before = rpu.dovi_profile; + let el_type_before = rpu.el_type.as_ref().map(|value| format!("{value:?}")); + match rpu.convert_with_mode(request.mode) { + Ok(()) => match rpu.write_hevc_unspec62_nalu() { + Ok(out) => RpuConvertResult { + ok: true, + profile_before: Some(profile_before), + profile_after: Some(rpu.dovi_profile), + el_type_before, + el_type_after: rpu.el_type.as_ref().map(|value| format!("{value:?}")), + rpu_hex: Some(hex_encode(&out)), + rpu_base64: Some(BASE64.encode(&out)), + error: None, + }, + Err(error) => convert_error(profile_before, el_type_before, error.to_string()), + }, + Err(error) => convert_error(profile_before, el_type_before, error.to_string()), + } + } + Err(error) => convert_error_empty(error.to_string()), + }, + Err(error) => convert_error_empty(error), + }; + serde_json::to_string(&result).ok() +} + +fn convert_error( + profile_before: u8, + el_type_before: Option, + error: String, +) -> RpuConvertResult { + RpuConvertResult { + ok: false, + profile_before: Some(profile_before), + profile_after: None, + el_type_before, + el_type_after: None, + rpu_hex: None, + rpu_base64: None, + error: Some(error), + } +} + +fn convert_error_empty(error: String) -> RpuConvertResult { + RpuConvertResult { + ok: false, + profile_before: None, + profile_after: None, + el_type_before: None, + el_type_after: None, + rpu_hex: None, + rpu_base64: None, + error: Some(error), + } +} + +impl RpuRequest { + fn rpu_bytes(&self) -> Result, String> { + if !self.rpu_base64.is_blank() { + return BASE64 + .decode(self.rpu_base64.as_bytes()) + .map_err(|error| error.to_string()); + } + hex_decode(&self.rpu_hex) + } +} + +trait Blank { + fn is_blank(&self) -> bool; +} + +impl Blank for String { + fn is_blank(&self) -> bool { + self.trim().is_empty() + } +} + +fn hex_decode(value: &str) -> Result, String> { + let clean = value + .chars() + .filter(|ch| !ch.is_whitespace() && *ch != ':') + .collect::(); + if clean.len() % 2 != 0 { + return Err("hex length must be even".to_string()); + } + (0..clean.len()) + .step_by(2) + .map(|index| { + u8::from_str_radix(&clean[index..index + 2], 16) + .map_err(|error| error.to_string()) + }) + .collect() +} + +fn hex_encode(bytes: &[u8]) -> String { + bytes.iter().map(|byte| format!("{byte:02x}")).collect() +} diff --git a/src/env.rs b/src/env.rs new file mode 100644 index 0000000..4f52881 --- /dev/null +++ b/src/env.rs @@ -0,0 +1,6 @@ +pub trait FluxaEnv: Send + Sync + 'static { + fn fetch_http(url: &str, timeout_ms: u32) -> Result<(u16, String), String>; + fn get_storage(key: &str) -> Result, String>; + fn set_storage(key: &str, value: Option<&str>) -> Result<(), String>; + fn now_utc_ms() -> i64; +} diff --git a/src/external_sync.rs b/src/external_sync.rs new file mode 100644 index 0000000..aa30cdc --- /dev/null +++ b/src/external_sync.rs @@ -0,0 +1,578 @@ +use crate::content_identity::{base_content_id, parse_episode_locator}; +use serde_json::{json, Map, Value}; + +const TRAKT_API_BASE_URL: &str = "https://api.trakt.tv"; + +pub(crate) fn trakt_has_client(api_key: &str) -> bool { + !api_key.trim().is_empty() +} + +pub(crate) fn trakt_bearer(token: &str) -> String { + format!("Bearer {token}") +} + +pub(crate) fn trakt_scrobble_url(action: &str) -> String { + format!("{TRAKT_API_BASE_URL}/scrobble/{action}") +} + +pub(crate) fn trakt_playback_url(content_type: Option<&str>) -> String { + match content_type.filter(|value| !value.trim().is_empty()) { + Some(content_type) => format!("{TRAKT_API_BASE_URL}/sync/playback/{content_type}"), + None => format!("{TRAKT_API_BASE_URL}/sync/playback"), + } +} + +pub(crate) fn trakt_token_expires_at(created_at_seconds: i64, expires_in_seconds: i64) -> i64 { + let refresh_buffer_seconds = 5 * 60; + let effective_expires_in = (expires_in_seconds - refresh_buffer_seconds).max(0); + (created_at_seconds * 1000) + (effective_expires_in * 1000) +} + +fn number_to_i32(value: &Value) -> Option { + value.as_i64().and_then(|value| i32::try_from(value).ok()) +} + +pub(crate) fn trakt_content_id_from_ids_json(ids_json: &str) -> Option { + let ids: Value = serde_json::from_str(ids_json).ok()?; + ids.get("imdb") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .or_else(|| { + ids.get("tmdb") + .and_then(number_to_i32) + .map(|id| format!("tmdb:{id}")) + }) + .or_else(|| { + ids.get("tvdb") + .and_then(number_to_i32) + .map(|id| format!("tvdb:{id}")) + }) + .or_else(|| { + ids.get("slug") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .map(|slug| format!("trakt:{slug}")) + }) + .or_else(|| { + ids.get("trakt") + .and_then(number_to_i32) + .map(|id| format!("trakt:{id}")) + }) +} + +pub(crate) fn trakt_ids_from_content_id_json(raw_id: &str) -> Option { + let imdb = regex::Regex::new(r"tt\d+") + .ok()? + .find(raw_id) + .map(|m| m.as_str().to_string()); + let mut ids = Map::new(); + if let Some(imdb) = imdb { + ids.insert("imdb".to_string(), Value::String(imdb)); + return serde_json::to_string(&Value::Object(ids)).ok(); + } + + let prefix_number = |prefix: &str| { + raw_id + .strip_prefix(prefix) + .and_then(|rest| rest.split(':').next()) + .and_then(|value| value.parse::().ok()) + }; + + if let Some(tmdb) = prefix_number("tmdb:") { + ids.insert("tmdb".to_string(), json!(tmdb)); + } else if let Some(tvdb) = prefix_number("tvdb:") { + ids.insert("tvdb".to_string(), json!(tvdb)); + } else if let Some(trakt) = prefix_number("trakt:") { + ids.insert("trakt".to_string(), json!(trakt)); + } else if let Some(tmdb) = raw_id + .split(':') + .next() + .and_then(|value| value.parse::().ok()) + { + ids.insert("tmdb".to_string(), json!(tmdb)); + } + + if ids.is_empty() { + None + } else { + serde_json::to_string(&Value::Object(ids)).ok() + } +} + +pub(crate) fn trakt_episode_locator_json(video_id: &str) -> Option { + let (_, season, episode) = parse_episode_locator(video_id)?; + serde_json::to_string(&json!({ + "season": season, + "episode": episode + })) + .ok() +} + +pub(crate) fn trakt_show_id_from_episode_id(video_id: &str) -> String { + if parse_episode_locator(video_id).is_some() { + base_content_id(video_id) + } else { + video_id.to_string() + } +} + +pub(crate) fn trakt_scrobble_media_id( + parent_id: &str, + video_id: Option<&str>, + media_type: &str, +) -> String { + if media_type != "series" { + return video_id.unwrap_or(parent_id).to_string(); + } + let Some(video_id) = video_id.filter(|value| !value.is_empty()) else { + return parent_id.to_string(); + }; + let Some((_, season, episode)) = parse_episode_locator(video_id) else { + return video_id.to_string(); + }; + format!("{parent_id}:{season}:{episode}") +} + +pub(crate) fn trakt_oauth_error_code(body: &str) -> Option { + let value: Value = serde_json::from_str(body).ok()?; + value + .get("error") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + +fn episode_season_number(episode: &Value) -> Option<(i32, i32)> { + let parsed = episode + .get("id") + .and_then(Value::as_str) + .and_then(parse_episode_locator); + let season = episode + .get("season") + .and_then(number_to_i32) + .or_else(|| parsed.as_ref().map(|(_, season, _)| *season)); + let number = episode + .get("number") + .and_then(number_to_i32) + .or_else(|| parsed.as_ref().map(|(_, _, episode)| *episode)); + season.zip(number) +} + +pub(crate) fn trakt_history_request_json(meta_json: &str, episodes_json: &str) -> Option { + let meta: Value = serde_json::from_str(meta_json).ok()?; + let episodes: Vec = serde_json::from_str(episodes_json).unwrap_or_default(); + let meta_id = meta.get("id").and_then(Value::as_str).unwrap_or(""); + let ids_json = trakt_ids_from_content_id_json(meta_id).or_else(|| { + episodes + .first() + .and_then(|episode| episode.get("id").and_then(Value::as_str)) + .and_then(trakt_ids_from_content_id_json) + })?; + let ids: Value = serde_json::from_str(&ids_json).ok()?; + + if meta.get("type").and_then(Value::as_str) == Some("movie") { + return serde_json::to_string(&json!({ + "movies": [{ "ids": ids }] + })) + .ok(); + } + + let target_episodes = if episodes.is_empty() { + meta.get("lastVideoId") + .and_then(Value::as_str) + .or_else(|| meta.get("id").and_then(Value::as_str)) + .and_then(parse_episode_locator) + .map(|(_, season, episode)| { + vec![json!({ + "season": season, + "number": episode + })] + }) + .unwrap_or_default() + } else { + episodes + }; + + let mut seasons = std::collections::BTreeMap::>::new(); + for episode in target_episodes.iter().filter_map(episode_season_number) { + seasons.entry(episode.0).or_default().push(episode.1); + } + if seasons.is_empty() { + return None; + } + + let seasons = seasons + .into_iter() + .map(|(season, mut episodes)| { + episodes.sort_unstable(); + episodes.dedup(); + json!({ + "number": season, + "episodes": episodes.into_iter().map(|number| json!({ "number": number })).collect::>() + }) + }) + .collect::>(); + + serde_json::to_string(&json!({ + "shows": [{ + "ids": ids, + "seasons": seasons + }] + })) + .ok() +} + +fn trakt_id_from_source(source: &Value) -> Option { + let ids = source.get("ids")?; + ids.get("imdb") + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .or_else(|| { + ids.get("tmdb") + .and_then(Value::as_i64) + .map(|n| format!("tmdb:{n}")) + }) +} + +pub(crate) fn trakt_playback_items_to_library_json(items_json: &str) -> Option { + let items: Vec = serde_json::from_str(items_json).ok()?; + let result: Vec = items.iter().filter_map(trakt_playback_item_to_library).collect(); + serde_json::to_string(&result).ok() +} + +fn trakt_playback_item_to_library(item: &Value) -> Option { + let movie = item.get("movie"); + let show = item.get("show"); + let episode = item.get("episode"); + let source = movie.or(show)?; + let id = trakt_id_from_source(source)?; + let progress = item.get("progress").and_then(Value::as_f64).unwrap_or(0.0); + if progress < 1.0 { return None; } + let title = source.get("title").or_else(|| source.get("name")) + .and_then(Value::as_str).unwrap_or("Untitled"); + let episode_title = episode.and_then(|e| e.get("title")).and_then(Value::as_str).unwrap_or(""); + let ep_runtime = episode.and_then(|e| e.get("runtime")).and_then(Value::as_f64); + let runtime_min = ep_runtime + .or_else(|| source.get("runtime").and_then(Value::as_f64)) + .unwrap_or(if movie.is_some() { 100.0 } else { 45.0 }); + let duration_sec = (runtime_min * 60.0) as i64; + let time_offset_sec = ((progress / 100.0) * duration_sec as f64).round() as i64; + let content_type = if movie.is_some() { "movie" } else { "series" }; + let last_video_id = if let Some(ep) = episode { + let show_imdb = show + .and_then(|s| s.get("ids")) + .and_then(|ids| ids.get("imdb")) + .and_then(Value::as_str) + .unwrap_or("trakt"); + let season = ep.get("season").and_then(Value::as_i64).unwrap_or(0); + let number = ep.get("number").and_then(Value::as_i64).unwrap_or(0); + format!("{show_imdb}:{season}:{number}") + } else { + id.clone() + }; + let episode_season = episode.and_then(|e| e.get("season")).and_then(Value::as_i64); + let episode_number = episode.and_then(|e| e.get("number")).and_then(Value::as_i64); + let saved_at = item.get("paused_at").and_then(Value::as_str).unwrap_or(""); + Some(json!({ + "id": id, + "name": title, + "type": content_type, + "timeOffset": time_offset_sec, + "duration": duration_sec, + "lastVideoId": last_video_id, + "lastEpisodeName": if episode_title.is_empty() { Value::Null } else { Value::String(episode_title.to_string()) }, + "lastEpisodeSeason": episode_season, + "lastEpisodeNumber": episode_number, + "savedAt": saved_at, + "reason": "trakt" + })) +} + +pub(crate) fn trakt_watchlist_to_items_json(movies_json: &str, shows_json: &str) -> Option { + let movies: Vec = serde_json::from_str(movies_json).unwrap_or_default(); + let shows: Vec = serde_json::from_str(shows_json).unwrap_or_default(); + let mut items: Vec = Vec::new(); + for entry in &movies { + let movie = entry.get("movie")?; + let id = trakt_id_from_source(movie)?; + let name = movie.get("title").and_then(Value::as_str).unwrap_or(""); + items.push(json!({ "id": id, "name": name, "type": "movie", "source": "trakt" })); + } + for entry in &shows { + let show = entry.get("show")?; + let id = trakt_id_from_source(show)?; + let name = show.get("title").and_then(Value::as_str).unwrap_or(""); + items.push(json!({ "id": id, "name": name, "type": "series", "source": "trakt" })); + } + serde_json::to_string(&items).ok() +} + +pub(crate) fn trakt_watched_to_ids_json(movies_json: &str, shows_json: &str) -> Option { + let movies: Vec = serde_json::from_str(movies_json).unwrap_or_default(); + let shows: Vec = serde_json::from_str(shows_json).unwrap_or_default(); + let mut ids: serde_json::Map = serde_json::Map::new(); + for entry in &movies { + if let Some(imdb) = entry.get("movie") + .and_then(|m| m.get("ids")) + .and_then(|ids| ids.get("imdb")) + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) + { + ids.insert(imdb.to_string(), Value::Bool(true)); + } + } + for entry in &shows { + let imdb = match entry.get("show") + .and_then(|s| s.get("ids")) + .and_then(|ids| ids.get("imdb")) + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) + { + Some(s) => s, + None => continue, + }; + let seasons = entry.get("seasons").and_then(Value::as_array).cloned().unwrap_or_default(); + for season in &seasons { + let s_num = season.get("number").and_then(Value::as_i64).unwrap_or(0); + let episodes = season.get("episodes").and_then(Value::as_array).cloned().unwrap_or_default(); + for ep in &episodes { + let e_num = ep.get("number").and_then(Value::as_i64).unwrap_or(0); + if s_num > 0 && e_num > 0 { + ids.insert(format!("{imdb}:{s_num}:{e_num}"), Value::Bool(true)); + } + } + } + } + serde_json::to_string(&Value::Object(ids)).ok() +} + +pub(crate) fn merge_external_watchlist_json(local_json: &str, external_json: &str) -> String { + let mut local: Vec = serde_json::from_str(local_json).unwrap_or_default(); + let external: Vec = serde_json::from_str(external_json).unwrap_or_default(); + let local_ids: std::collections::HashSet = local.iter() + .filter_map(|i| i.get("id").and_then(Value::as_str).map(str::to_string)) + .collect(); + for item in external { + if let Some(id) = item.get("id").and_then(Value::as_str) { + if !local_ids.contains(id) { + local.push(item); + } + } + } + serde_json::to_string(&local).unwrap_or_else(|_| "[]".to_string()) +} + +pub(crate) fn merge_external_watched_json(local_json: &str, external_json: &str) -> String { + let mut local: serde_json::Map = serde_json::from_str(local_json).unwrap_or_default(); + let external: serde_json::Map = serde_json::from_str(external_json).unwrap_or_default(); + for (id, val) in external { + if val.as_bool() == Some(true) && !local.contains_key(&id) { + local.insert(id, Value::Bool(true)); + } + } + serde_json::to_string(&Value::Object(local)).unwrap_or_else(|_| "{}".to_string()) +} + +pub(crate) fn merge_continue_watching_lists_json( + local_json: &str, + external_json: &str, + progress_json: &str, +) -> Option { + let local: Vec = serde_json::from_str(local_json).unwrap_or_default(); + let external: Vec = serde_json::from_str(external_json).unwrap_or_default(); + let progress: serde_json::Map = serde_json::from_str(progress_json).unwrap_or_default(); + + fn item_id(item: &Value) -> String { + item.get("id").or_else(|| item.get("_id")) + .and_then(Value::as_str).unwrap_or("").to_string() + } + + fn saved_at_ms(item: &Value) -> i64 { + item.get("savedAt").and_then(Value::as_str) + .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok()) + .map(|dt: chrono::DateTime| dt.timestamp_millis()) + .unwrap_or(0) + } + + let local_by_id: std::collections::HashMap = local.iter() + .map(|item| (item_id(item), item)) + .collect(); + let external_by_id: std::collections::HashMap = external.iter() + .map(|item| (item_id(item), item)) + .collect(); + + fn local_saved_at_from_progress(progress: &serde_json::Map, id: &str) -> i64 { + progress.get(id) + .and_then(|entry| entry.get("savedAt")) + .and_then(Value::as_str) + .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok()) + .map(|dt: chrono::DateTime| dt.timestamp_millis()) + .unwrap_or(0) + } + + let mut merged: Vec = Vec::new(); + for ext_item in &external { + let id = item_id(ext_item); + let local_time = local_saved_at_from_progress(&progress, &id); + let ext_time = saved_at_ms(ext_item); + if local_time > ext_time { + let local_item = local_by_id.get(&id).copied(); + merged.push(local_item.cloned().unwrap_or_else(|| ext_item.clone())); + } else { + merged.push(ext_item.clone()); + } + } + for local_item in &local { + let id = item_id(local_item); + if !external_by_id.contains_key(&id) { + merged.push(local_item.clone()); + } + } + + serde_json::to_string(&merged).ok() +} + +pub(crate) fn simkl_watching_to_items_json(shows_json: &str, movies_json: &str) -> Option { + let shows: Vec = serde_json::from_str(shows_json).unwrap_or_default(); + let movies: Vec = serde_json::from_str(movies_json).unwrap_or_default(); + let mut items: Vec = Vec::new(); + for entry in &shows { + let show = entry.get("show")?; + let ids = show.get("ids")?; + let imdb = ids.get("imdb").and_then(Value::as_str).filter(|s| !s.is_empty())?; + let title = show.get("title").and_then(Value::as_str).unwrap_or(""); + let poster = show.get("poster").and_then(Value::as_str) + .map(|p| format!("https://simkl.in/posters/{p}_m.jpg")); + let saved_at = entry.get("last_watched").and_then(Value::as_str).unwrap_or_default(); + items.push(json!({ + "id": imdb, "type": "series", "name": title, + "poster": poster, "continueWatchingBadge": "upNext", + "savedAt": saved_at, "reason": "simkl" + })); + } + for entry in &movies { + let movie = entry.get("movie")?; + let ids = movie.get("ids")?; + let imdb = ids.get("imdb").and_then(Value::as_str).filter(|s| !s.is_empty())?; + let title = movie.get("title").and_then(Value::as_str).unwrap_or(""); + let poster = movie.get("poster").and_then(Value::as_str) + .map(|p| format!("https://simkl.in/posters/{p}_m.jpg")); + let saved_at = entry.get("last_watched").and_then(Value::as_str).unwrap_or_default(); + items.push(json!({ + "id": imdb, "type": "movie", "name": title, + "poster": poster, "savedAt": saved_at, "reason": "simkl" + })); + } + serde_json::to_string(&items).ok() +} + +pub(crate) fn simkl_watchlist_to_items_json(shows_json: &str, movies_json: &str) -> Option { + let shows: Vec = serde_json::from_str(shows_json).unwrap_or_default(); + let movies: Vec = serde_json::from_str(movies_json).unwrap_or_default(); + let mut items: Vec = Vec::new(); + for entry in &shows { + let show = entry.get("show")?; + let ids = show.get("ids")?; + let imdb = ids.get("imdb").and_then(Value::as_str).filter(|s| !s.is_empty())?; + let title = show.get("title").and_then(Value::as_str).unwrap_or(""); + let poster = show.get("poster").and_then(Value::as_str) + .map(|p| format!("https://simkl.in/posters/{p}_m.jpg")); + items.push(json!({ "id": imdb, "name": title, "type": "series", "source": "simkl", "poster": poster })); + } + for entry in &movies { + let movie = entry.get("movie")?; + let ids = movie.get("ids")?; + let imdb = ids.get("imdb").and_then(Value::as_str).filter(|s| !s.is_empty())?; + let title = movie.get("title").and_then(Value::as_str).unwrap_or(""); + let poster = movie.get("poster").and_then(Value::as_str) + .map(|p| format!("https://simkl.in/posters/{p}_m.jpg")); + items.push(json!({ "id": imdb, "name": title, "type": "movie", "source": "simkl", "poster": poster })); + } + serde_json::to_string(&items).ok() +} + +pub(crate) fn simkl_watched_to_ids_json(shows_json: &str, movies_json: &str) -> Option { + let shows: Vec = serde_json::from_str(shows_json).unwrap_or_default(); + let movies: Vec = serde_json::from_str(movies_json).unwrap_or_default(); + let mut ids: serde_json::Map = serde_json::Map::new(); + for entry in &shows { + if let Some(imdb) = entry.get("show") + .and_then(|s| s.get("ids")) + .and_then(|i| i.get("imdb")) + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) + { + ids.insert(imdb.to_string(), Value::Bool(true)); + } + } + for entry in &movies { + if let Some(imdb) = entry.get("movie") + .and_then(|m| m.get("ids")) + .and_then(|i| i.get("imdb")) + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) + { + ids.insert(imdb.to_string(), Value::Bool(true)); + } + } + serde_json::to_string(&Value::Object(ids)).ok() +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::Value; + + #[test] + fn trakt_ids_support_stremio_episode_ids() { + assert_eq!( + trakt_ids_from_content_id_json("tt1234567:1:2") + .and_then(|json| serde_json::from_str::(&json).ok()) + .and_then(|ids| ids.get("imdb").and_then(Value::as_str).map(str::to_owned)) + .as_deref(), + Some("tt1234567") + ); + assert_eq!( + trakt_ids_from_content_id_json("tmdb:42:1:2") + .and_then(|json| serde_json::from_str::(&json).ok()) + .and_then(|ids| ids.get("tmdb").and_then(Value::as_i64)), + Some(42) + ); + } + + #[test] + fn history_request_builds_show_seasons_from_episode_ids() { + let request = trakt_history_request_json( + r#"{"id":"tt1234567","name":"Show","type":"series","poster":null}"#, + r#"[{"id":"tt1234567:1:2","name":null,"season":null,"number":null,"released":null,"thumbnail":null}]"#, + ) + .and_then(|json| serde_json::from_str::(&json).ok()) + .expect("history request"); + + assert_eq!( + request + .get("shows") + .and_then(Value::as_array) + .and_then(|shows| shows.first()) + .and_then(|show| show.get("seasons")) + .and_then(Value::as_array) + .and_then(|seasons| seasons.first()) + .and_then(|season| season.get("number")) + .and_then(Value::as_i64), + Some(1) + ); + assert!(request.get("movies").is_none()); + } + + #[test] + fn trakt_oauth_error_code_extracts_structured_error() { + assert_eq!( + trakt_oauth_error_code(r#"{"error":"authorization_pending"}"#).as_deref(), + Some("authorization_pending") + ); + assert_eq!(trakt_oauth_error_code("{}"), None); + } +} diff --git a/src/headless_adapter_plan.rs b/src/headless_adapter_plan.rs new file mode 100644 index 0000000..97b57b2 --- /dev/null +++ b/src/headless_adapter_plan.rs @@ -0,0 +1,183 @@ +use crate::addon_protocol; +use crate::stream_policy; +use serde::Deserialize; +use serde_json::{json, Value}; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ProviderAvailabilityRequest { + #[serde(default)] + addons: Vec, + #[serde(default)] + plugin_names: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct DetailStreamAttempt { + request_id: String, + #[serde(default)] + streams: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct DetailStreamResultRequest { + #[serde(default)] + attempts: Vec, + #[serde(default)] + has_stream_providers: bool, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PrefetchPlanRequest { + #[serde(default)] + streams: Vec, +} + +pub(crate) fn provider_availability_plan_json(request_json: &str) -> Option { + let request = serde_json::from_str::(request_json).ok()?; + let has_stremio_stream_addon = request.addons.iter().any(|addon| { + addon + .get("manifest") + .is_some_and(|manifest| addon_protocol::supports_resource(&manifest.to_string(), "stream", None, None)) + }); + let plugin_names = stable_non_empty_strings(request.plugin_names); + serde_json::to_string(&json!({ + "hasStremioStreamAddons": has_stremio_stream_addon, + "hasPluginStreamProviders": !plugin_names.is_empty(), + "hasStreamProviders": has_stremio_stream_addon || !plugin_names.is_empty(), + "pluginNames": plugin_names + })) + .ok() +} + +pub(crate) fn detail_stream_result_plan_json(request_json: &str) -> Option { + let request = serde_json::from_str::(request_json).ok()?; + let selected = request + .attempts + .iter() + .find(|attempt| !attempt.streams.is_empty()); + let streams = selected + .map(|attempt| attempt.streams.clone()) + .unwrap_or_default(); + let resolved_request_id = selected + .map(|attempt| Value::String(attempt.request_id.clone())) + .unwrap_or(Value::Null); + serde_json::to_string(&json!({ + "streams": streams, + "availableAddons": available_addons(&streams), + "resolvedRequestId": resolved_request_id, + "hasStreamProviders": request.has_stream_providers + })) + .ok() +} + +pub(crate) fn prefetch_detail_streams_plan_json(request_json: &str) -> Option { + let request = serde_json::from_str::(request_json).ok()?; + let prewarm_url = request.streams.iter().find_map(playable_torrent_url); + serde_json::to_string(&json!({ + "count": request.streams.len(), + "prewarmUrl": prewarm_url, + "shouldPrewarmTorrent": prewarm_url.is_some() + })) + .ok() +} + +pub(crate) fn direct_playback_policy_json() -> String { + json!({ + "metaDetailTimeoutMs": 3500, + "streamDetailTimeoutMs": 2500 + }) + .to_string() +} + +fn available_addons(streams: &[Value]) -> Vec { + streams + .iter() + .filter_map(|stream| stream.get("addonName").and_then(Value::as_str)) + .map(str::trim) + .filter(|value| !value.is_empty()) + .fold(Vec::new(), |mut acc, value| { + if !acc.iter().any(|existing| existing == value) { + acc.push(value.to_string()); + } + acc + }) +} + +fn playable_torrent_url(stream: &Value) -> Option { + let url = stream + .get("playableUrl") + .or_else(|| stream.get("url")) + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty())?; + stream_policy::is_torrent_playback_url(url).then(|| url.to_string()) +} + +fn stable_non_empty_strings(values: Vec) -> Vec { + values + .into_iter() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .fold(Vec::new(), |mut acc, value| { + if !acc.contains(&value) { + acc.push(value); + } + acc + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::Value; + + #[test] + fn provider_availability_is_core_planned_from_addons_and_plugins() { + let value: Value = serde_json::from_str( + &provider_availability_plan_json( + r#"{"addons":[{"manifest":{"resources":["catalog",{"name":"stream","types":["movie"]}]}}],"pluginNames":["CS3","CS3"," "]}"#, + ) + .unwrap(), + ) + .unwrap(); + + assert_eq!(value["hasStremioStreamAddons"], true); + assert_eq!(value["hasPluginStreamProviders"], true); + assert_eq!(value["hasStreamProviders"], true); + assert_eq!(value["pluginNames"], json!(["CS3"])); + } + + #[test] + fn detail_stream_result_stops_on_first_non_empty_attempt_without_reordering_streams() { + let value: Value = serde_json::from_str( + &detail_stream_result_plan_json( + r#"{"hasStreamProviders":true,"attempts":[{"requestId":"tt1","streams":[]},{"requestId":"tt1:1:1","streams":[{"url":"b","addonName":"B"},{"url":"a","addonName":"A"},{"url":"b2","addonName":"B"}]}]}"#, + ) + .unwrap(), + ) + .unwrap(); + + assert_eq!(value["resolvedRequestId"], "tt1:1:1"); + assert_eq!(value["streams"][0]["url"], "b"); + assert_eq!(value["streams"][1]["url"], "a"); + assert_eq!(value["availableAddons"], json!(["B", "A"])); + } + + #[test] + fn prefetch_plan_selects_first_torrent_playable_url_only() { + let value: Value = serde_json::from_str( + &prefetch_detail_streams_plan_json( + r#"{"streams":[{"playableUrl":"https://video.example/file.mp4"},{"playableUrl":"magnet:?xt=urn:btih:abc"},{"playableUrl":"magnet:?xt=urn:btih:def"}]}"#, + ) + .unwrap(), + ) + .unwrap(); + + assert_eq!(value["count"], 3); + assert_eq!(value["shouldPrewarmTorrent"], true); + assert_eq!(value["prewarmUrl"], "magnet:?xt=urn:btih:abc"); + } +} diff --git a/src/headless_engine/addons.rs b/src/headless_engine/addons.rs new file mode 100644 index 0000000..73f0104 --- /dev/null +++ b/src/headless_engine/addons.rs @@ -0,0 +1,119 @@ +use super::helpers::{current_generation, normalize_error, upsert_by_key}; +use super::{EffectResultInput, HeadlessEngine}; +use crate::runtime::{EffectEnvelope, EffectKind}; +use serde_json::{json, Value}; + +pub(super) fn dispatch_install( + engine: &mut HeadlessEngine, + transport_url: String, + force_refresh: Option, +) -> Vec { + let generation = engine.bump_generation("addonGeneration"); + engine.state["addons"]["installing"] = json!(transport_url.clone()); + engine.state["addons"]["error"] = Value::Null; + vec![engine.effect( + EffectKind::FetchAddonManifest, + generation, + json!({ + "transportUrl": transport_url, + "forceRefresh": force_refresh.unwrap_or(false) + }), + )] +} + +pub(super) fn dispatch_refresh( + engine: &mut HeadlessEngine, + profile: Option, + force_refresh: Option, +) -> Vec { + let generation = engine.bump_generation("addonGeneration"); + let profile_value = profile.unwrap_or_else(|| engine.state["profile"]["active"].clone()); + vec![engine.effect( + EffectKind::RefreshInstalledAddons, + generation, + json!({ + "profile": profile_value, + "forceRefresh": force_refresh.unwrap_or(true) + }), + )] +} + +pub(super) fn dispatch_resource( + engine: &mut HeadlessEngine, + transport_url: String, + resource: String, + content_type: String, + id: String, + extra: Option, +) -> Vec { + let generation = engine.bump_generation("addonGeneration"); + engine.state["addons"]["lastResourceRequest"] = json!({ + "transportUrl": transport_url, + "resource": resource, + "contentType": content_type, + "id": id, + "extra": extra.unwrap_or(Value::Null) + }); + vec![engine.effect( + EffectKind::FetchAddonResource, + generation, + engine.state["addons"]["lastResourceRequest"].clone(), + )] +} + +pub(super) fn complete( + engine: &mut HeadlessEngine, + effect_type: &str, + generation: u64, + result: &EffectResultInput, +) -> Vec { + match effect_type { + "fetchAddonManifest" => { + if generation == current_generation(&engine.state, "addonGeneration") { + if result.status == "ok" { + let manifest = result.value.clone(); + let id = manifest["transportUrl"] + .as_str() + .or_else(|| manifest["id"].as_str()) + .unwrap_or("unknown") + .to_string(); + upsert_by_key(&mut engine.state["addons"]["installed"], "id", &id, manifest); + engine.state["addons"]["installing"] = Value::Null; + engine.state["addons"]["error"] = Value::Null; + } else { + engine.state["addons"]["installing"] = Value::Null; + engine.state["addons"]["error"] = normalize_error(result.error.clone()); + } + } + } + "refreshInstalledAddons" => { + if generation == current_generation(&engine.state, "addonGeneration") { + if result.status == "ok" { + engine.state["home"]["userAddons"] = result + .value + .get("addons") + .cloned() + .unwrap_or_else(|| result.value.clone()); + engine.state["addons"]["installed"] = + engine.state["home"]["userAddons"].clone(); + engine.state["addons"]["error"] = Value::Null; + } else { + engine.state["addons"]["error"] = normalize_error(result.error.clone()); + } + } + } + "fetchAddonResource" => { + if generation == current_generation(&engine.state, "addonGeneration") { + if result.status == "ok" { + engine.state["addons"]["lastResourceResult"] = result.value.clone(); + engine.state["addons"]["error"] = Value::Null; + } else { + engine.state["addons"]["lastResourceResult"] = Value::Null; + engine.state["addons"]["error"] = normalize_error(result.error.clone()); + } + } + } + _ => {} + } + vec![] +} diff --git a/src/headless_engine/auth.rs b/src/headless_engine/auth.rs new file mode 100644 index 0000000..ed80bf0 --- /dev/null +++ b/src/headless_engine/auth.rs @@ -0,0 +1,117 @@ +use super::helpers::{current_generation, normalize_error}; +use super::{EffectResultInput, HeadlessEngine}; +use crate::runtime::{EffectEnvelope, EffectKind}; +use serde_json::{json, Value}; + +pub(super) fn dispatch_flow( + engine: &mut HeadlessEngine, + provider: String, + mode: String, +) -> Vec { + let generation = engine.bump_generation("authGeneration"); + engine.state["auth"] = json!({ + "provider": provider, + "mode": mode, + "isLoading": true, + "error": Value::Null, + "generation": generation + }); + vec![engine.effect( + EffectKind::RunAuthFlow, + generation, + json!({ + "provider": engine.state["auth"]["provider"].clone(), + "mode": engine.state["auth"]["mode"].clone() + }), + )] +} + +pub(super) fn dispatch_exchange( + engine: &mut HeadlessEngine, + provider: String, + code: String, + code_verifier: Option, + profile: Option, +) -> Vec { + let generation = engine.bump_generation("authGeneration"); + engine.state["auth"] = json!({ + "provider": provider, + "mode": "exchange", + "isLoading": true, + "error": Value::Null, + "generation": generation + }); + vec![engine.effect( + EffectKind::ExchangeAuthCode, + generation, + json!({ + "provider": engine.state["auth"]["provider"].clone(), + "code": code, + "codeVerifier": code_verifier, + "profile": profile.unwrap_or_else(|| engine.state["profile"]["active"].clone()) + }), + )] +} + +pub(super) fn dispatch_token_refresh( + engine: &mut HeadlessEngine, + provider: String, + profile: Value, +) -> Vec { + let generation = engine.bump_generation("authGeneration"); + engine.state["auth"] = json!({ + "provider": provider, + "mode": "refresh", + "isLoading": true, + "error": Value::Null, + "generation": generation + }); + vec![engine.effect( + EffectKind::RefreshAuthToken, + generation, + json!({ + "provider": engine.state["auth"]["provider"].clone(), + "profile": profile + }), + )] +} + +pub(super) fn complete( + engine: &mut HeadlessEngine, + effect_type: &str, + generation: u64, + result: &EffectResultInput, +) -> Vec { + match effect_type { + "runAuthFlow" => { + if generation == current_generation(&engine.state, "authGeneration") { + engine.state["auth"]["isLoading"] = json!(false); + if result.status == "ok" { + engine.state["auth"]["result"] = result.value.clone(); + engine.state["auth"]["error"] = Value::Null; + } else { + engine.state["auth"]["error"] = normalize_error(result.error.clone()); + } + } + } + "exchangeAuthCode" | "refreshAuthToken" => { + if generation == current_generation(&engine.state, "authGeneration") { + engine.state["auth"]["isLoading"] = json!(false); + if result.status == "ok" { + engine.state["auth"]["result"] = result.value.clone(); + let updated_profile = + engine.state["auth"]["result"].get("profile").cloned(); + if let Some(updated_profile) = updated_profile { + engine.state["profile"]["active"] = updated_profile.clone(); + engine.state["home"]["activeProfile"] = updated_profile; + } + engine.state["auth"]["error"] = Value::Null; + } else { + engine.state["auth"]["error"] = normalize_error(result.error.clone()); + } + } + } + _ => {} + } + vec![] +} diff --git a/src/headless_engine/calendar.rs b/src/headless_engine/calendar.rs new file mode 100644 index 0000000..8ec8d34 --- /dev/null +++ b/src/headless_engine/calendar.rs @@ -0,0 +1,75 @@ +use super::helpers::{active_profile_id, current_generation, normalize_error}; +use super::{EffectResultInput, HeadlessEngine}; +use crate::runtime::{EffectEnvelope, EffectKind}; +use serde_json::{json, Value}; + +pub(super) fn dispatch( + engine: &mut HeadlessEngine, + profile: Option, + year: i32, + month: i32, + planned_items: Option, +) -> Vec { + let generation = engine.bump_generation("calendarGeneration"); + let profile_value = profile.unwrap_or_else(|| engine.state["profile"]["active"].clone()); + engine.state["calendar"] = json!({ + "year": year, + "month": month, + "isLoading": true, + "items": [], + "error": Value::Null, + "generation": generation + }); + vec![engine.effect( + EffectKind::ReadCalendarMonth, + generation, + json!({ + "profileId": active_profile_id(&engine.state, &profile_value), + "profile": profile_value, + "year": year, + "month": month.clamp(1, 12), + "plannedItems": planned_items.unwrap_or_else(|| json!([])) + }), + )] +} + +pub(super) fn complete( + engine: &mut HeadlessEngine, + generation: u64, + result: &EffectResultInput, + effect: &Value, +) -> Vec { + if generation != current_generation(&engine.state, "calendarGeneration") { + return vec![]; + } + engine.state["calendar"]["isLoading"] = json!(false); + if result.status == "ok" { + let items = result.value.get("items").cloned().unwrap_or_else(|| result.value.clone()); + let local_items = result.value.get("localItems").cloned().unwrap_or_else(|| json!([])); + let external_items = result.value.get("externalItems").cloned().unwrap_or_else(|| json!([])); + engine.state["calendar"]["items"] = items.clone(); + engine.state["calendar"]["localItems"] = local_items; + engine.state["calendar"]["externalItems"] = external_items.clone(); + engine.state["calendar"]["error"] = Value::Null; + let mut follow_up = vec![ + engine.effect(EffectKind::UpdateCalendarWidget, generation, json!({ + "profile": effect["payload"]["profile"].clone(), + "items": items.clone() + })), + engine.effect(EffectKind::NotifyReleasedEpisodes, generation, json!({ + "profile": effect["payload"]["profile"].clone(), + "items": items + })), + ]; + if external_items.as_array().is_some_and(|i| !i.is_empty()) { + follow_up.push(engine.effect(EffectKind::ReplaceExternalContinueWatching, generation, json!({ + "profileId": effect["payload"]["profileId"].clone(), + "items": external_items + }))); + } + follow_up + } else { + engine.state["calendar"]["error"] = normalize_error(result.error.clone()); + vec![] + } +} diff --git a/src/headless_engine/contracts.rs b/src/headless_engine/contracts.rs new file mode 100644 index 0000000..240ae4c --- /dev/null +++ b/src/headless_engine/contracts.rs @@ -0,0 +1,321 @@ +use crate::runtime::EffectEnvelope; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Clone, Debug, Deserialize)] +#[serde( + rename_all = "camelCase", + rename_all_fields = "camelCase", + tag = "type" +)] +pub(super) enum AppAction { + #[serde(rename = "navigationRequested")] + NavigationRequested { + route: String, + params: Option, + }, + #[serde(rename = "detailLoadRequested")] + DetailLoadRequested { + content_type: String, + id: String, + language: Option, + source_addon_transport_url: Option, + source_addon_catalog_type: Option, + profile: Option, + }, + #[serde(rename = "detailLocalStateRequested")] + DetailLocalStateRequested { + primary_id: String, + fallback_id: Option, + content_type: String, + profile: Option, + }, + #[serde(rename = "detailSecondaryRequested")] + DetailSecondaryRequested { + content_type: String, + id: String, + language: Option, + profile: Option, + }, + #[serde(rename = "detailPrefetchRequested")] + DetailPrefetchRequested { + content_type: String, + id: String, + stream_lookup_id: String, + title: Option, + original_name: Option, + year: Option, + language: Option, + profile: Option, + }, + #[serde(rename = "detailStreamsRequested")] + DetailStreamsRequested { + content_type: String, + request_ids: Vec, + detail: Option, + season_episodes: Option>, + language: Option, + profile: Option, + }, + #[serde(rename = "detailSelectedAddonChanged")] + DetailSelectedAddonChanged { addon: Option }, + #[serde(rename = "metaDetailRequested")] + MetaDetailRequested { + content_type: String, + id: String, + language: Option, + profile: Option, + }, + #[serde(rename = "directPlaybackRequested")] + DirectPlaybackRequested { + meta: Value, + language: Option, + profile: Option, + }, + #[serde(rename = "introSegmentsRequested")] + IntroSegmentsRequested { + imdb_id: String, + season: i32, + episode: i32, + title: Option, + use_intro_db: bool, + use_ani_skip: bool, + }, + #[serde(rename = "introImdbIdRequested")] + IntroImdbIdRequested { + meta: Value, + video_id: Option, + language: Option, + }, + #[serde(rename = "playerLoadStreamsRequested")] + PlayerLoadStreamsRequested { + content_type: String, + id: String, + current_video_id: Option, + initial_video_id: Option, + initial_streams: Option>, + initial_stream_index: Option, + saved_url: Option, + saved_title: Option, + source_selection_mode: Option, + regex_pattern: Option, + preferred_binge_group: Option, + title: Option, + original_name: Option, + year: Option, + language: Option, + profile: Option, + }, + #[serde(rename = "playerStreamsLoaded")] + PlayerStreamsLoaded { + streams: Vec, + current_video_id: Option, + initial_stream_index: Option, + saved_url: Option, + saved_title: Option, + source_selection_mode: Option, + regex_pattern: Option, + preferred_binge_group: Option, + }, + #[serde(rename = "playerStreamsFailed")] + PlayerStreamsFailed { error_code: Option }, + #[serde(rename = "playerResolvePlaybackRequested")] + PlayerResolvePlaybackRequested { + url: String, + stream: Option, + current_video_id: Option, + title: Option, + }, + #[serde(rename = "scrobbleRequested")] + ScrobbleRequested { + token: String, + meta_type: String, + item_id: String, + progress: f64, + action_name: String, + profile: Option, + }, + #[serde(rename = "profileActivated")] + ProfileActivated { profile: Value }, + #[serde(rename = "homeLoadRequested")] + HomeLoadRequested { + profile: Option, + language: Option, + force: Option, + }, + #[serde(rename = "libraryHydrateRequested")] + LibraryHydrateRequested { profile_id: Option }, + #[serde(rename = "toggleWatchlistRequested")] + ToggleWatchlistRequested { item: Value }, + #[serde(rename = "setFeedbackRequested")] + SetFeedbackRequested { + id: String, + value: Option, + meta: Value, + }, + #[serde(rename = "clearPlaybackProgressRequested")] + ClearPlaybackProgressRequested { profile: Option, meta: Value }, + #[serde(rename = "savePlaybackProgressRequested")] + SavePlaybackProgressRequested { + profile: Option, + meta: Value, + time_offset: i64, + duration: i64, + last_video_id: Option, + last_stream_index: Option, + last_episode_name: Option, + last_episode_season: Option, + last_episode_number: Option, + last_episode_thumbnail: Option, + last_stream_url: Option, + last_stream_title: Option, + last_audio_language: Option, + last_subtitle_language: Option, + scrobble_trakt_pause: Option, + }, + #[serde(rename = "markWatchedRequested")] + MarkWatchedRequested { + series_id: String, + video_ids: Vec, + watched: Option, + meta: Option, + episodes: Option>, + profile: Option, + }, + #[serde(rename = "addonInstallRequested")] + AddonInstallRequested { + transport_url: String, + force_refresh: Option, + }, + #[serde(rename = "addonsRefreshRequested")] + AddonsRefreshRequested { + profile: Option, + force_refresh: Option, + }, + #[serde(rename = "addonResourceRequested")] + AddonResourceRequested { + transport_url: String, + resource: String, + content_type: String, + id: String, + extra: Option, + }, + #[serde(rename = "searchRequested")] + SearchRequested { + query: String, + profile: Option, + language: Option, + }, + #[serde(rename = "discoverRequested")] + DiscoverRequested { + content_type: String, + filters: Option, + profile: Option, + language: Option, + }, + #[serde(rename = "discoverCatalogFiltersRequested")] + DiscoverCatalogFiltersRequested { + content_type: String, + selected_catalog_key: Option, + profile: Option, + language: Option, + }, + #[serde(rename = "catalogPageRequested")] + CatalogPageRequested { + category_id: String, + transport_url: Option, + content_type: String, + catalog_id: String, + skip: Option, + genre: Option, + search: Option, + }, + #[serde(rename = "detailSeasonRequested")] + DetailSeasonRequested { + series_id: String, + season: i32, + profile: Option, + language: Option, + }, + #[serde(rename = "playerNextEpisodeCardShown")] + PlayerNextEpisodeCardShown { + content_type: String, + series_id: String, + next_video_id: String, + title: Option, + original_name: Option, + year: Option, + language: Option, + profile: Option, + }, + #[serde(rename = "subtitleLoadRequested")] + SubtitleLoadRequested { + stream: Value, + content_type: String, + id: String, + extra_args: Option, + }, + #[serde(rename = "externalSyncRequested")] + ExternalSyncRequested { + provider: String, + profile: Option, + language: Option, + }, + #[serde(rename = "authFlowRequested")] + AuthFlowRequested { provider: String, mode: String }, + #[serde(rename = "authExchangeRequested")] + AuthExchangeRequested { + provider: String, + code: String, + code_verifier: Option, + profile: Option, + }, + #[serde(rename = "authRefreshRequested")] + AuthRefreshRequested { provider: String, profile: Value }, + #[serde(rename = "externalIntegrationSyncRequested")] + ExternalIntegrationSyncRequested { + provider: String, + profile: Value, + language: Option, + }, + #[serde(rename = "settingsChanged")] + SettingsChanged { key: String, value: Value }, + #[serde(rename = "calendarMonthRequested")] + CalendarMonthRequested { + profile: Option, + year: i32, + month: i32, + #[serde(rename = "plannedItems")] + planned_items: Option, + }, + #[serde(rename = "offlineDownloadRequested")] + OfflineDownloadRequested { + meta: Value, + stream: Value, + video_id: Option, + video: Option, + subtitle: Option, + profile_id: Option, + language: Option, + }, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct EffectResultInput { + #[serde(default)] + pub effect_id: String, + #[serde(default)] + pub status: String, + #[serde(default)] + pub value: Value, + #[serde(default)] + pub error: Value, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct DispatchResult { + pub state: Value, + pub effects: Vec, +} diff --git a/src/headless_engine/detail.rs b/src/headless_engine/detail.rs new file mode 100644 index 0000000..9dbbd47 --- /dev/null +++ b/src/headless_engine/detail.rs @@ -0,0 +1,383 @@ +use super::helpers::{ + active_profile_id, current_generation, normalize_error, normalize_meta_trailers, + value_array_is_empty, visible_streams, +}; +use super::{EffectResultInput, HeadlessEngine}; +use crate::runtime::{EffectEnvelope, EffectKind}; +use serde_json::{json, Value}; + +pub(super) fn dispatch_load( + engine: &mut HeadlessEngine, + content_type: String, + id: String, + language: Option, + source_addon_transport_url: Option, + source_addon_catalog_type: Option, + profile: Option, +) -> Vec { + let generation = engine.bump_generation("detailGeneration"); + engine.state["detail"] = json!({ + "contentType": content_type, + "id": id, + "language": language.clone().unwrap_or_else(|| "en".to_string()), + "profile": profile.clone().unwrap_or(Value::Null), + "isLoading": true, + "isLoadingStreams": false, + "meta": Value::Null, + "streams": [], + "visibleStreams": [], + "selectedAddon": Value::Null, + "availableAddons": [], + "loadingAddonNames": [], + "seasonEpisodes": [], + "savedPlayback": Value::Null, + "watchedVideoIds": [], + "similarItems": [], + "trailers": [], + "error": Value::Null, + "generation": generation + }); + vec![ + engine.effect( + EffectKind::FetchMetaDetail, + generation, + json!({ + "contentType": content_type, + "id": id, + "language": language.unwrap_or_else(|| "en".to_string()), + "sourceAddonTransportUrl": source_addon_transport_url.unwrap_or_default(), + "sourceAddonCatalogType": source_addon_catalog_type.unwrap_or_default(), + "profile": profile.unwrap_or(Value::Null) + }), + ), + engine.effect( + EffectKind::ReadPlaybackProgress, + generation, + json!({ "id": engine.state["detail"]["id"].clone() }), + ), + ] +} + +pub(super) fn dispatch_local_state( + engine: &mut HeadlessEngine, + primary_id: String, + fallback_id: Option, + content_type: String, + profile: Option, +) -> Vec { + let generation = current_generation(&engine.state, "detailGeneration") + .max(engine.bump_generation("detailGeneration")); + vec![engine.effect( + EffectKind::ReadDetailLocalState, + generation, + json!({ + "primaryId": primary_id, + "fallbackId": fallback_id, + "contentType": content_type, + "profile": profile.unwrap_or(Value::Null) + }), + )] +} + +pub(super) fn dispatch_secondary( + engine: &mut HeadlessEngine, + content_type: String, + id: String, + language: Option, + profile: Option, +) -> Vec { + let generation = current_generation(&engine.state, "detailGeneration") + .max(engine.bump_generation("detailGeneration")); + vec![engine.effect( + EffectKind::FetchDetailSecondary, + generation, + json!({ + "contentType": content_type, + "id": id, + "language": language.unwrap_or_else(|| "en".to_string()), + "profile": profile.unwrap_or(Value::Null) + }), + )] +} + +#[allow(clippy::too_many_arguments)] +pub(super) fn dispatch_prefetch( + engine: &mut HeadlessEngine, + content_type: String, + id: String, + stream_lookup_id: String, + title: Option, + original_name: Option, + year: Option, + language: Option, + profile: Option, +) -> Vec { + let generation = current_generation(&engine.state, "detailGeneration"); + vec![engine.effect( + EffectKind::PrefetchDetailStreams, + generation, + json!({ + "contentType": content_type, + "id": id, + "streamLookupId": stream_lookup_id, + "title": title.unwrap_or_default(), + "originalName": original_name, + "year": year, + "language": language.unwrap_or_else(|| "en".to_string()), + "profile": profile.unwrap_or(Value::Null) + }), + )] +} + +pub(super) fn dispatch_streams( + engine: &mut HeadlessEngine, + content_type: String, + request_ids: Vec, + detail: Option, + season_episodes: Option>, + language: Option, + profile: Option, +) -> Vec { + let generation = engine.bump_generation("detailStreamsGeneration"); + engine.state["detail"]["isLoadingStreams"] = json!(true); + engine.state["detail"]["streams"] = json!([]); + engine.state["detail"]["visibleStreams"] = json!([]); + engine.state["detail"]["selectedAddon"] = Value::Null; + engine.state["detail"]["availableAddons"] = json!([]); + engine.state["detail"]["loadingAddonNames"] = json!([]); + vec![engine.effect( + EffectKind::FetchDetailStreams, + generation, + json!({ + "contentType": content_type, + "requestIds": request_ids, + "detail": detail.unwrap_or(Value::Null), + "seasonEpisodes": season_episodes.unwrap_or_default(), + "language": language.unwrap_or_else(|| "en".to_string()), + "profile": profile.unwrap_or(Value::Null) + }), + )] +} + +pub(super) fn dispatch_selected_addon_changed( + engine: &mut HeadlessEngine, + addon: Option, +) -> Vec { + let selected = addon.and_then(|value| { + let trimmed = value.trim().to_string(); + if trimmed.is_empty() { None } else { Some(trimmed) } + }); + engine.state["detail"]["selectedAddon"] = + selected.as_ref().map(|value| json!(value)).unwrap_or(Value::Null); + engine.state["detail"]["visibleStreams"] = + visible_streams(&engine.state["detail"]["streams"], selected.as_deref()); + vec![] +} + +pub(super) fn dispatch_meta_detail( + engine: &mut HeadlessEngine, + content_type: String, + id: String, + language: Option, + profile: Option, +) -> Vec { + let generation = engine.bump_generation("lookupGeneration"); + vec![engine.effect( + EffectKind::FetchMetaDetailLookup, + generation, + json!({ + "contentType": content_type, + "id": id, + "language": language.unwrap_or_else(|| "en".to_string()), + "profile": profile.unwrap_or(Value::Null) + }), + )] +} + +pub(super) fn dispatch_season( + engine: &mut HeadlessEngine, + series_id: String, + season: i32, + profile: Option, + language: Option, +) -> Vec { + let generation = current_generation(&engine.state, "detailGeneration") + .max(engine.bump_generation("detailGeneration")); + let profile_value = profile.unwrap_or_else(|| engine.state["profile"]["active"].clone()); + engine.state["detail"]["seasonLoading"] = json!(season); + vec![engine.effect( + EffectKind::FetchSeasonEpisodes, + generation, + json!({ + "seriesId": series_id, + "season": season.max(0), + "profileId": active_profile_id(&engine.state, &profile_value), + "profile": profile_value, + "language": language.unwrap_or_else(|| "en".to_string()) + }), + )] +} + +pub(super) fn complete( + engine: &mut HeadlessEngine, + effect_type: &str, + generation: u64, + result: &EffectResultInput, +) -> Vec { + match effect_type { + "fetchMetaDetail" => { + if generation == current_generation(&engine.state, "detailGeneration") { + if result.status == "ok" { + engine.state["detail"]["trailers"] = normalize_meta_trailers(&result.value); + engine.state["detail"]["meta"] = result.value.clone(); + engine.state["detail"]["isLoading"] = json!(false); + engine.state["detail"]["error"] = Value::Null; + } else { + engine.state["detail"]["isLoading"] = json!(false); + engine.state["detail"]["error"] = normalize_error(result.error.clone()); + } + } + } + "readPlaybackProgress" => { + if generation == current_generation(&engine.state, "detailGeneration") { + engine.state["detail"]["savedPlayback"] = if result.status == "ok" { + result.value.clone() + } else { + Value::Null + }; + } + } + "readDetailLocalState" => { + if generation == current_generation(&engine.state, "detailGeneration") { + if result.status == "ok" { + engine.state["detail"]["savedPlayback"] = + result.value.get("savedPlayback").cloned().unwrap_or(Value::Null); + engine.state["detail"]["localWatchedVideoIds"] = result + .value + .get("localWatchedVideoIds") + .cloned() + .unwrap_or_else(|| json!([])); + engine.state["detail"]["isInWatchlist"] = result + .value + .get("isInWatchlist") + .cloned() + .unwrap_or_else(|| json!(false)); + engine.state["detail"]["feedback"] = + result.value.get("feedback").cloned().unwrap_or(Value::Null); + engine.state["detail"]["hasStreamProviders"] = result + .value + .get("hasStreamProviders") + .cloned() + .unwrap_or_else(|| json!(false)); + engine.state["detail"]["userAddons"] = result + .value + .get("userAddons") + .cloned() + .unwrap_or_else(|| json!([])); + } else { + engine.state["detail"]["error"] = normalize_error(result.error.clone()); + } + } + } + "fetchDetailSecondary" => { + if generation == current_generation(&engine.state, "detailGeneration") { + if result.status == "ok" { + engine.state["detail"]["watchedVideoIds"] = result + .value + .get("watchedVideoIds") + .cloned() + .unwrap_or_else(|| json!([])); + engine.state["detail"]["similarItems"] = result + .value + .get("similarItems") + .cloned() + .unwrap_or_else(|| json!([])); + if value_array_is_empty(&engine.state["detail"]["trailers"]) { + engine.state["detail"]["trailers"] = result + .value + .get("trailers") + .cloned() + .unwrap_or_else(|| json!([])); + } + } else { + engine.state["detail"]["error"] = normalize_error(result.error.clone()); + } + } + } + "prefetchDetailStreams" => { + if generation == current_generation(&engine.state, "detailGeneration") { + if result.status == "ok" { + engine.state["detail"]["lastPrefetch"] = result.value.clone(); + } else { + engine.state["detail"]["lastPrefetchError"] = + normalize_error(result.error.clone()); + } + } + } + "fetchDetailStreams" => { + if generation == current_generation(&engine.state, "detailStreamsGeneration") { + engine.state["detail"]["isLoadingStreams"] = json!(false); + if result.status == "ok" { + engine.state["detail"]["streams"] = result + .value + .get("streams") + .cloned() + .unwrap_or_else(|| json!([])); + engine.state["detail"]["selectedAddon"] = Value::Null; + engine.state["detail"]["visibleStreams"] = + engine.state["detail"]["streams"].clone(); + engine.state["detail"]["availableAddons"] = result + .value + .get("availableAddons") + .cloned() + .unwrap_or_else(|| json!([])); + engine.state["detail"]["loadingAddonNames"] = json!([]); + engine.state["detail"]["resolvedRequestId"] = result + .value + .get("resolvedRequestId") + .cloned() + .unwrap_or(Value::Null); + engine.state["detail"]["hasStreamProviders"] = result + .value + .get("hasStreamProviders") + .cloned() + .unwrap_or_else(|| json!(false)); + engine.state["detail"]["streamsError"] = Value::Null; + } else { + engine.state["detail"]["streamsError"] = normalize_error(result.error.clone()); + engine.state["detail"]["loadingAddonNames"] = json!([]); + } + } + } + "fetchMetaDetailLookup" => { + if generation == current_generation(&engine.state, "lookupGeneration") { + if result.status == "ok" { + engine.state["lookup"]["trailers"] = normalize_meta_trailers(&result.value); + engine.state["lookup"]["metaDetail"] = result.value.clone(); + engine.state["lookup"]["error"] = Value::Null; + } else { + engine.state["lookup"]["metaDetail"] = Value::Null; + engine.state["lookup"]["trailers"] = json!([]); + engine.state["lookup"]["error"] = normalize_error(result.error.clone()); + } + } + } + "fetchSeasonEpisodes" => { + if generation == current_generation(&engine.state, "detailGeneration") { + engine.state["detail"]["seasonLoading"] = Value::Null; + if result.status == "ok" { + engine.state["detail"]["seasonEpisodes"] = result + .value + .get("episodes") + .cloned() + .unwrap_or_else(|| result.value.clone()); + engine.state["detail"]["error"] = Value::Null; + } else { + engine.state["detail"]["error"] = normalize_error(result.error.clone()); + } + } + } + _ => {} + } + vec![] +} diff --git a/src/headless_engine/discover.rs b/src/headless_engine/discover.rs new file mode 100644 index 0000000..6dbbac4 --- /dev/null +++ b/src/headless_engine/discover.rs @@ -0,0 +1,101 @@ +use super::helpers::{active_profile_id, current_generation, normalize_error}; +use super::{EffectResultInput, HeadlessEngine}; +use crate::runtime::{EffectEnvelope, EffectKind}; +use serde_json::{json, Value}; + +pub(super) fn dispatch_discover( + engine: &mut HeadlessEngine, + content_type: String, + filters: Option, + profile: Option, + language: Option, +) -> Vec { + let generation = engine.bump_generation("discoverGeneration"); + let profile_value = profile.unwrap_or_else(|| engine.state["profile"]["active"].clone()); + engine.state["discover"] = json!({ + "contentType": content_type, + "filters": filters.clone().unwrap_or(Value::Null), + "isLoading": true, + "results": [], + "error": Value::Null, + "generation": generation + }); + vec![engine.effect( + EffectKind::RunDiscover, + generation, + json!({ + "contentType": engine.state["discover"]["contentType"].clone(), + "filters": engine.state["discover"]["filters"].clone(), + "profileId": active_profile_id(&engine.state, &profile_value), + "profile": profile_value, + "language": language.unwrap_or_else(|| "en".to_string()) + }), + )] +} + +pub(super) fn dispatch_catalog_filters( + engine: &mut HeadlessEngine, + content_type: String, + selected_catalog_key: Option, + profile: Option, + language: Option, +) -> Vec { + let generation = engine.bump_generation("discoverGeneration"); + let profile_value = profile.unwrap_or_else(|| engine.state["profile"]["active"].clone()); + vec![engine.effect( + EffectKind::ReadDiscoverCatalogFilters, + generation, + json!({ + "contentType": content_type, + "selectedCatalogKey": selected_catalog_key, + "profileId": active_profile_id(&engine.state, &profile_value), + "profile": profile_value, + "language": language.unwrap_or_else(|| "en".to_string()) + }), + )] +} + +pub(super) fn complete( + engine: &mut HeadlessEngine, + effect_type: &str, + generation: u64, + result: &EffectResultInput, +) -> Vec { + match effect_type { + "runDiscover" => { + if generation == current_generation(&engine.state, "discoverGeneration") { + engine.state["discover"]["isLoading"] = json!(false); + if result.status == "ok" { + engine.state["discover"]["results"] = result + .value + .get("results") + .cloned() + .unwrap_or_else(|| result.value.clone()); + engine.state["discover"]["resultSources"] = result + .value + .get("resultSources") + .cloned() + .unwrap_or_else(|| json!({})); + engine.state["discover"]["error"] = Value::Null; + } else { + engine.state["discover"]["error"] = normalize_error(result.error.clone()); + } + } + } + "readDiscoverCatalogFilters" => { + if generation == current_generation(&engine.state, "discoverGeneration") { + if result.status == "ok" { + engine.state["discover"]["catalogs"] = + result.value.get("catalogs").cloned().unwrap_or_else(|| json!([])); + engine.state["discover"]["genres"] = + result.value.get("genres").cloned().unwrap_or_else(|| json!([])); + engine.state["discover"]["error"] = Value::Null; + } else { + engine.state["discover"]["error"] = normalize_error(result.error.clone()); + } + } + } + _ => {} + } + vec![] +} diff --git a/src/headless_engine/helpers.rs b/src/headless_engine/helpers.rs new file mode 100644 index 0000000..a33d5b7 --- /dev/null +++ b/src/headless_engine/helpers.rs @@ -0,0 +1,172 @@ +use serde_json::{json, Value}; + +pub(super) fn current_generation(state: &Value, key: &str) -> u64 { + state["_runtime"][key].as_u64().unwrap_or(0) +} + +pub(super) fn pending_effect(state: &Value, effect_id: &str) -> Option { + state["pendingEffects"] + .as_array()? + .iter() + .find(|effect| effect["id"].as_str() == Some(effect_id)) + .cloned() +} + +pub(super) fn remove_pending_effect(state: &mut Value, effect_id: &str) { + if let Some(effects) = state["pendingEffects"].as_array_mut() { + effects.retain(|effect| effect["id"].as_str() != Some(effect_id)); + } +} + +pub(super) fn normalize_error(error: Value) -> Value { + if error.is_null() { + json!({ "code": "generic" }) + } else { + error + } +} + +pub(super) fn error_code(error: &Value) -> String { + error["code"] + .as_str() + .or_else(|| error.as_str()) + .unwrap_or("generic") + .to_string() +} + +pub(super) fn active_profile_id(state: &Value, profile: &Value) -> String { + profile["id"] + .as_str() + .or_else(|| state["profile"]["activeProfileId"].as_str()) + .filter(|value| !value.trim().is_empty()) + .unwrap_or("guest") + .to_string() +} + +pub(super) fn visible_streams(streams: &Value, selected_addon: Option<&str>) -> Value { + let Some(selected_addon) = selected_addon + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + return streams.clone(); + }; + let selected_lower = selected_addon.to_lowercase(); + let filtered = streams + .as_array() + .map(|items| { + items + .iter() + .filter(|stream| { + stream["addonName"] + .as_str() + .map(|addon_name| addon_name.trim().to_lowercase() == selected_lower) + .unwrap_or(false) + }) + .cloned() + .collect::>() + }) + .unwrap_or_default(); + json!(filtered) +} + +pub(super) fn value_array_is_empty(value: &Value) -> bool { + value.as_array().map(Vec::is_empty).unwrap_or(true) +} + +pub(super) fn normalize_meta_trailers(meta: &Value) -> Value { + let trailers = meta["trailers"] + .as_array() + .map(|items| { + items + .iter() + .filter_map(normalize_meta_trailer) + .collect::>() + }) + .unwrap_or_default(); + Value::Array(trailers) +} + +fn normalize_meta_trailer(trailer: &Value) -> Option { + let youtube_id = non_blank_str(trailer, "ytId").or_else(|| { + non_blank_str(trailer, "source") + .filter(|value| !value.starts_with("http://") && !value.starts_with("https://")) + }); + let url = non_blank_str(trailer, "externalUrl") + .or_else(|| non_blank_str(trailer, "url")) + .or_else(|| { + youtube_id + .as_ref() + .map(|value| format!("https://www.youtube.com/watch?v={value}")) + })?; + let id = youtube_id.clone().unwrap_or_else(|| url.clone()); + let item_type = non_blank_str(trailer, "type").unwrap_or_else(|| "Trailer".to_string()); + let title = non_blank_str(trailer, "name") + .or_else(|| non_blank_str(trailer, "title")) + .or_else(|| non_blank_str(trailer, "description")) + .unwrap_or_else(|| item_type.clone()); + let thumbnail = non_blank_str(trailer, "thumbnail").or_else(|| { + youtube_id + .as_ref() + .map(|value| format!("https://i.ytimg.com/vi/{value}/hqdefault.jpg")) + }); + Some(json!({ + "id": id, + "title": title, + "type": item_type, + "url": url, + "thumbnail": thumbnail, + "source": "addon" + })) +} + +pub(super) fn non_blank_str(value: &Value, key: &str) -> Option { + value[key] + .as_str() + .map(str::trim) + .filter(|text| !text.is_empty()) + .map(str::to_string) +} + +pub(super) fn should_sync_watched_state(profile: Option<&Value>, meta: Option<&Value>) -> bool { + let Some(meta) = meta else { return false }; + if meta["id"] + .as_str() + .filter(|value| !value.trim().is_empty()) + .is_none() + { + return false; + } + let Some(profile) = profile else { return false }; + let is_guest = profile["isGuest"].as_bool().unwrap_or(false); + let has_trakt_token = profile["traktAccessToken"] + .as_str() + .map(|value| !value.trim().is_empty()) + .unwrap_or(false); + !is_guest || has_trakt_token +} + +pub(super) fn upsert_by_key(target: &mut Value, key: &str, value: &str, item: Value) { + if !target.is_array() { + *target = json!([]); + } + let Some(items) = target.as_array_mut() else { return }; + if let Some(existing) = items + .iter_mut() + .find(|existing| existing[key].as_str() == Some(value)) + { + *existing = item; + } else { + items.push(item); + } +} + +pub(super) fn merge_object(target: &mut Value, patch: Value) { + match (target.as_object_mut(), patch) { + (Some(target), Value::Object(patch)) => { + for (key, value) in patch { + target.insert(key, value); + } + } + _ => {} + } +} diff --git a/src/headless_engine/home.rs b/src/headless_engine/home.rs new file mode 100644 index 0000000..ad7c236 --- /dev/null +++ b/src/headless_engine/home.rs @@ -0,0 +1,166 @@ +use super::helpers::{active_profile_id, current_generation, normalize_error}; +use super::{EffectResultInput, HeadlessEngine}; +use crate::runtime::{EffectEnvelope, EffectKind}; +use serde_json::{json, Value}; + +pub(super) fn dispatch_load( + engine: &mut HeadlessEngine, + profile: Option, + language: Option, + force: Option, +) -> Vec { + let generation = engine.bump_generation("homeGeneration"); + let profile_value = profile.unwrap_or_else(|| engine.state["profile"]["active"].clone()); + let profile_id = active_profile_id(&engine.state, &profile_value); + engine.state["home"] = json!({ + "isLoading": true, + "categories": [], + "continueWatching": [], + "userAddons": [], + "metadataFeeds": [], + "billboard": Value::Null, + "error": Value::Null, + "generation": generation + }); + vec![engine.effect( + EffectKind::ReadHomeBootstrap, + generation, + json!({ + "profileId": profile_id, + "profile": profile_value, + "language": language.unwrap_or_else(|| "en".to_string()), + "force": force.unwrap_or(false) + }), + )] +} + +pub(super) fn dispatch_direct_playback( + engine: &mut HeadlessEngine, + meta: Value, + language: Option, + profile: Option, +) -> Vec { + let generation = engine.bump_generation("playbackPrepGeneration"); + engine.state["home"]["isDirectLoading"] = json!(true); + vec![engine.effect( + EffectKind::PrepareDirectPlayback, + generation, + json!({ + "meta": meta, + "language": language.unwrap_or_else(|| "en".to_string()), + "profile": profile.unwrap_or(Value::Null) + }), + )] +} + +pub(super) fn dispatch_catalog_page( + engine: &mut HeadlessEngine, + category_id: String, + transport_url: Option, + content_type: String, + catalog_id: String, + skip: Option, + genre: Option, + search: Option, +) -> Vec { + let generation = engine.bump_generation("homeGeneration"); + engine.state["home"]["paging"] = json!({ + "categoryId": category_id, + "isLoading": true, + "error": Value::Null + }); + vec![engine.effect( + EffectKind::FetchCatalogPage, + generation, + json!({ + "categoryId": engine.state["home"]["paging"]["categoryId"].clone(), + "transportUrl": transport_url, + "contentType": content_type, + "catalogId": catalog_id, + "skip": skip.unwrap_or(0).max(0), + "genre": genre, + "search": search + }), + )] +} + +pub(super) fn complete( + engine: &mut HeadlessEngine, + effect_type: &str, + generation: u64, + result: &EffectResultInput, +) -> Vec { + match effect_type { + "readHomeBootstrap" => { + if generation == current_generation(&engine.state, "homeGeneration") { + engine.state["home"]["isLoading"] = json!(false); + if result.status == "ok" { + engine.state["home"]["categories"] = result + .value + .get("categories") + .cloned() + .unwrap_or_else(|| json!([])); + engine.state["home"]["continueWatching"] = result + .value + .get("continueWatching") + .cloned() + .unwrap_or_else(|| json!([])); + engine.state["home"]["watchlist"] = result + .value + .get("watchlist") + .cloned() + .unwrap_or_else(|| json!([])); + engine.state["home"]["userAddons"] = result + .value + .get("userAddons") + .cloned() + .unwrap_or_else(|| json!([])); + engine.state["home"]["metadataFeeds"] = result + .value + .get("metadataFeeds") + .cloned() + .unwrap_or_else(|| json!([])); + engine.state["home"]["billboard"] = result + .value + .get("billboard") + .cloned() + .unwrap_or(Value::Null); + engine.state["home"]["error"] = Value::Null; + } else { + engine.state["home"]["error"] = normalize_error(result.error.clone()); + } + } + } + "prepareDirectPlayback" => { + if generation == current_generation(&engine.state, "playbackPrepGeneration") { + engine.state["home"]["isDirectLoading"] = json!(false); + if result.status == "ok" { + engine.state["player"]["directPlaybackTarget"] = result.value.clone(); + engine.state["player"]["playerError"] = Value::Null; + } else { + engine.state["player"]["directPlaybackTarget"] = Value::Null; + engine.state["player"]["playerError"] = + json!(super::helpers::error_code(&result.error)); + } + } + } + "fetchCatalogPage" => { + if generation == current_generation(&engine.state, "homeGeneration") { + engine.state["home"]["paging"]["isLoading"] = json!(false); + if result.status == "ok" { + engine.state["home"]["paging"]["items"] = result + .value + .get("items") + .cloned() + .unwrap_or_else(|| result.value.clone()); + engine.state["home"]["paging"]["error"] = Value::Null; + } else { + engine.state["home"]["paging"]["error"] = + normalize_error(result.error.clone()); + } + } + } + _ => {} + } + vec![] +} diff --git a/src/headless_engine/library.rs b/src/headless_engine/library.rs new file mode 100644 index 0000000..0221d4e --- /dev/null +++ b/src/headless_engine/library.rs @@ -0,0 +1,302 @@ +use super::helpers::{active_profile_id, current_generation, normalize_error, should_sync_watched_state}; +use super::{EffectResultInput, HeadlessEngine}; +use crate::runtime::{EffectEnvelope, EffectKind}; +use serde_json::{json, Value}; + +pub(super) fn dispatch_profile_activated( + engine: &mut HeadlessEngine, + profile: Value, +) -> Vec { + let profile_id = profile["id"] + .as_str() + .filter(|value| !value.is_empty()) + .unwrap_or("guest") + .to_string(); + engine.state["profile"]["active"] = profile.clone(); + engine.state["profile"]["activeProfileId"] = json!(profile_id.clone()); + engine.state["library"]["activeProfileId"] = json!(profile_id); + vec![] +} + +pub(super) fn dispatch_hydrate( + engine: &mut HeadlessEngine, + profile_id: Option, +) -> Vec { + let generation = engine.bump_generation("libraryGeneration"); + let resolved_profile_id = + profile_id.unwrap_or_else(|| active_profile_id(&engine.state, &Value::Null)); + engine.state["library"]["activeProfileId"] = json!(resolved_profile_id.clone()); + engine.state["library"]["isLoading"] = json!(true); + engine.state["library"]["error"] = Value::Null; + engine.state["library"]["generation"] = json!(generation); + vec![engine.effect( + EffectKind::ReadLibraryState, + generation, + json!({ "profileId": resolved_profile_id }), + )] +} + +pub(super) fn dispatch_toggle_watchlist( + engine: &mut HeadlessEngine, + item: Value, +) -> Vec { + let generation = engine.bump_generation("libraryGeneration"); + let profile_id = active_profile_id(&engine.state, &Value::Null); + engine.state["library"]["lastCommand"] = json!({ + "type": "toggleWatchlist", + "item": item + }); + vec![engine.effect( + EffectKind::WriteLibraryCommand, + generation, + json!({ + "profileId": profile_id, + "command": engine.state["library"]["lastCommand"].clone() + }), + )] +} + +pub(super) fn dispatch_set_feedback( + engine: &mut HeadlessEngine, + id: String, + value: Option, + meta: Value, +) -> Vec { + let generation = engine.bump_generation("libraryGeneration"); + vec![engine.effect( + EffectKind::WriteFeedback, + generation, + json!({ "id": id, "value": value, "meta": meta }), + )] +} + +pub(super) fn dispatch_clear_progress( + engine: &mut HeadlessEngine, + profile: Option, + meta: Value, +) -> Vec { + let generation = engine.bump_generation("libraryGeneration"); + vec![engine.effect( + EffectKind::ClearPlaybackProgress, + generation, + json!({ "profile": profile.unwrap_or(Value::Null), "meta": meta }), + )] +} + +#[allow(clippy::too_many_arguments)] +pub(super) fn dispatch_save_progress( + engine: &mut HeadlessEngine, + profile: Option, + meta: Value, + time_offset: i64, + duration: i64, + last_video_id: Option, + last_stream_index: Option, + last_episode_name: Option, + last_episode_season: Option, + last_episode_number: Option, + last_episode_thumbnail: Option, + last_stream_url: Option, + last_stream_title: Option, + last_audio_language: Option, + last_subtitle_language: Option, + scrobble_trakt_pause: Option, +) -> Vec { + let generation = engine.bump_generation("libraryGeneration"); + let profile_id = active_profile_id(&engine.state, &Value::Null); + let progress = json!({ + "meta": meta, + "timeOffset": time_offset.max(0), + "duration": duration.max(0), + "lastVideoId": last_video_id, + "lastStreamIndex": last_stream_index, + "lastEpisodeName": last_episode_name, + "lastEpisodeSeason": last_episode_season, + "lastEpisodeNumber": last_episode_number, + "lastEpisodeThumbnail": last_episode_thumbnail, + "lastStreamUrl": last_stream_url, + "lastStreamTitle": last_stream_title, + "lastAudioLanguage": last_audio_language, + "lastSubtitleLanguage": last_subtitle_language + }); + engine.state["library"]["pendingPlaybackProgress"] = progress.clone(); + vec![engine.effect( + EffectKind::WritePlaybackProgress, + generation, + json!({ + "profileId": profile_id, + "profile": profile.unwrap_or(Value::Null), + "scrobbleTraktPause": scrobble_trakt_pause.unwrap_or(true), + "progress": progress + }), + )] +} + +pub(super) fn dispatch_mark_watched( + engine: &mut HeadlessEngine, + series_id: String, + video_ids: Vec, + watched: Option, + meta: Option, + episodes: Option>, + profile: Option, +) -> Vec { + let generation = engine.bump_generation("libraryGeneration"); + let profile_id = active_profile_id(&engine.state, &Value::Null); + let watched_value = watched.unwrap_or(true); + let clean_video_ids: Vec = video_ids + .into_iter() + .filter(|value| !value.trim().is_empty()) + .fold(Vec::new(), |mut acc, value| { + if !acc.contains(&value) { + acc.push(value); + } + acc + }); + engine.state["library"]["lastCommand"] = json!({ + "type": "markWatched", + "seriesId": series_id, + "videoIds": clean_video_ids, + "watched": watched_value + }); + let mut effects = vec![engine.effect( + EffectKind::WriteLibraryCommand, + generation, + json!({ + "profileId": profile_id, + "command": engine.state["library"]["lastCommand"].clone() + }), + )]; + if should_sync_watched_state(profile.as_ref(), meta.as_ref()) { + effects.push(engine.effect( + EffectKind::SyncWatchedState, + generation, + json!({ + "profile": profile.unwrap_or(Value::Null), + "meta": meta.unwrap_or(Value::Null), + "episodes": episodes.unwrap_or_default(), + "watched": watched_value + }), + )); + } + effects +} + +pub(super) fn complete( + engine: &mut HeadlessEngine, + effect_type: &str, + generation: u64, + result: &EffectResultInput, +) -> Vec { + match effect_type { + "readLibraryState" => { + if generation == current_generation(&engine.state, "libraryGeneration") { + engine.state["library"]["isLoading"] = json!(false); + if result.status == "ok" { + engine.state["library"]["watchlist"] = result + .value + .get("watchlist") + .cloned() + .unwrap_or_else(|| json!([])); + engine.state["library"]["continueWatching"] = result + .value + .get("continueWatching") + .cloned() + .unwrap_or_else(|| json!([])); + engine.state["library"]["liked"] = result + .value + .get("liked") + .cloned() + .unwrap_or_else(|| json!([])); + engine.state["library"]["watched"] = result + .value + .get("watched") + .cloned() + .unwrap_or_else(|| json!({})); + engine.state["library"]["error"] = Value::Null; + } else { + engine.state["library"]["error"] = normalize_error(result.error.clone()); + } + } + } + "writeLibraryCommand" => { + if generation == current_generation(&engine.state, "libraryGeneration") { + if result.status == "ok" { + engine.state["library"]["lastWrite"] = result.value.clone(); + engine.state["library"]["lastWriteError"] = Value::Null; + if let Some(value) = + engine.state["library"]["lastWrite"].get("isInWatchlist") + { + engine.state["detail"]["isInWatchlist"] = value.clone(); + } + if let Some(value) = + engine.state["library"]["lastWrite"].get("localWatchedVideoIds") + { + engine.state["detail"]["localWatchedVideoIds"] = value.clone(); + } + } else { + engine.state["library"]["lastWriteError"] = + normalize_error(result.error.clone()); + } + } + } + "writeFeedback" => { + if generation == current_generation(&engine.state, "libraryGeneration") { + if result.status == "ok" { + engine.state["detail"]["feedback"] = + result.value.get("feedback").cloned().unwrap_or(Value::Null); + engine.state["library"]["lastWriteError"] = Value::Null; + } else { + engine.state["library"]["lastWriteError"] = + normalize_error(result.error.clone()); + } + } + } + "clearPlaybackProgress" => { + if generation == current_generation(&engine.state, "libraryGeneration") { + if result.status == "ok" { + engine.state["detail"]["savedPlayback"] = Value::Null; + engine.state["library"]["lastWriteError"] = Value::Null; + // Remove the dropped item from home.continueWatching so stale state + // doesn't reappear when the user navigates back to the home screen. + if let Some(dropped_id) = result.value.get("droppedId").and_then(Value::as_str) { + if let Some(cw) = engine.state["home"]["continueWatching"].as_array_mut() { + cw.retain(|item| { + item.get("id").and_then(Value::as_str) != Some(dropped_id) + }); + } + } + } else { + engine.state["library"]["lastWriteError"] = + normalize_error(result.error.clone()); + } + } + } + "writePlaybackProgress" => { + if generation == current_generation(&engine.state, "libraryGeneration") { + if result.status == "ok" { + engine.state["library"]["savedPlaybackProgress"] = + engine.state["library"]["pendingPlaybackProgress"].clone(); + engine.state["library"]["pendingPlaybackProgress"] = Value::Null; + engine.state["library"]["lastWriteError"] = Value::Null; + } else { + engine.state["library"]["lastWriteError"] = + normalize_error(result.error.clone()); + } + } + } + "syncWatchedState" => { + if generation == current_generation(&engine.state, "libraryGeneration") { + if result.status == "ok" { + engine.state["library"]["lastWatchedSync"] = result.value.clone(); + engine.state["library"]["lastWatchedSyncError"] = Value::Null; + } else { + engine.state["library"]["lastWatchedSyncError"] = + normalize_error(result.error.clone()); + } + } + } + _ => {} + } + vec![] +} diff --git a/src/headless_engine/mod.rs b/src/headless_engine/mod.rs new file mode 100644 index 0000000..e44bb3f --- /dev/null +++ b/src/headless_engine/mod.rs @@ -0,0 +1,1351 @@ +mod addons; +mod auth; +mod calendar; +mod contracts; +mod detail; +mod discover; +mod helpers; +mod home; +mod library; +mod navigation; +mod offline; +mod player; +mod search; +mod settings; +mod state; +mod sync; + +use crate::runtime::{EffectEnvelope, EffectKind}; +use contracts::{AppAction, DispatchResult}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use state::default_state; +use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Mutex, OnceLock}; + +pub(crate) use contracts::EffectResultInput; + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct HeadlessEngine { + #[serde(default)] + state: Value, + #[serde(default)] + next_effect_id: u64, +} + +impl Default for HeadlessEngine { + fn default() -> Self { + Self { + state: default_state(), + next_effect_id: 1, + } + } +} + +static ENGINE_COUNTER: AtomicU64 = AtomicU64::new(1); +static ENGINES: OnceLock>> = OnceLock::new(); + +pub(crate) fn create_headless_engine(initial_json: &str) -> u64 { + let mut engine = HeadlessEngine::default(); + if let Ok(initial_state) = serde_json::from_str::(initial_json) { + helpers::merge_object(&mut engine.state, initial_state); + } + if !engine.state["pendingEffects"].is_array() { + engine.state["pendingEffects"] = json!([]); + } + if let Ok(mut map) = engines().lock() { + let handle = ENGINE_COUNTER.fetch_add(1, Ordering::Relaxed); + map.insert(handle, engine); + handle + } else { + 0 + } +} + +pub(crate) fn destroy_headless_engine(handle: u64) -> bool { + engines() + .lock() + .ok() + .and_then(|mut map| map.remove(&handle)) + .is_some() +} + +pub(crate) fn headless_engine_snapshot_json(handle: u64) -> Option { + let map = engines().lock().ok()?; + serde_json::to_string(&map.get(&handle)?.state).ok() +} + +pub(crate) fn headless_engine_dispatch_json(handle: u64, action_json: &str) -> Option { + let action: AppAction = serde_json::from_str(action_json).ok()?; + let mut map = engines().lock().ok()?; + let engine = map.get_mut(&handle)?; + let effects = engine.dispatch(action); + engine.result_json(effects) +} + +pub(crate) fn headless_engine_complete_effect_json( + handle: u64, + result_json: &str, +) -> Option { + let result: EffectResultInput = serde_json::from_str(result_json).ok()?; + let mut map = engines().lock().ok()?; + let engine = map.get_mut(&handle)?; + let effects = engine.complete_effect(result); + engine.result_json(effects) +} + +impl HeadlessEngine { + fn dispatch(&mut self, action: AppAction) -> Vec { + match action { + AppAction::NavigationRequested { route, params } => { + navigation::dispatch(self, route, params) + } + AppAction::DetailLoadRequested { + content_type, + id, + language, + source_addon_transport_url, + source_addon_catalog_type, + profile, + } => detail::dispatch_load(self, content_type, id, language, source_addon_transport_url, source_addon_catalog_type, profile), + AppAction::DetailLocalStateRequested { + primary_id, + fallback_id, + content_type, + profile, + } => detail::dispatch_local_state(self, primary_id, fallback_id, content_type, profile), + AppAction::DetailSecondaryRequested { + content_type, + id, + language, + profile, + } => detail::dispatch_secondary(self, content_type, id, language, profile), + AppAction::DetailPrefetchRequested { + content_type, + id, + stream_lookup_id, + title, + original_name, + year, + language, + profile, + } => detail::dispatch_prefetch( + self, + content_type, + id, + stream_lookup_id, + title, + original_name, + year, + language, + profile, + ), + AppAction::DetailStreamsRequested { + content_type, + request_ids, + detail, + season_episodes, + language, + profile, + } => detail::dispatch_streams( + self, + content_type, + request_ids, + detail, + season_episodes, + language, + profile, + ), + AppAction::DetailSelectedAddonChanged { addon } => { + detail::dispatch_selected_addon_changed(self, addon) + } + AppAction::MetaDetailRequested { + content_type, + id, + language, + profile, + } => detail::dispatch_meta_detail(self, content_type, id, language, profile), + AppAction::DirectPlaybackRequested { + meta, + language, + profile, + } => home::dispatch_direct_playback(self, meta, language, profile), + AppAction::IntroSegmentsRequested { + imdb_id, + season, + episode, + title, + use_intro_db, + use_ani_skip, + } => player::dispatch_intro_segments( + self, + imdb_id, + season, + episode, + title, + use_intro_db, + use_ani_skip, + ), + AppAction::IntroImdbIdRequested { + meta, + video_id, + language, + } => player::dispatch_intro_imdb_id(self, meta, video_id, language), + AppAction::PlayerLoadStreamsRequested { + content_type, + id, + current_video_id, + initial_video_id, + initial_streams, + initial_stream_index, + saved_url, + saved_title, + source_selection_mode, + regex_pattern, + preferred_binge_group, + title, + original_name, + year, + language, + profile, + } => player::dispatch_load_streams( + self, + content_type, + id, + current_video_id, + initial_video_id, + initial_streams, + initial_stream_index, + saved_url, + saved_title, + source_selection_mode, + regex_pattern, + preferred_binge_group, + title, + original_name, + year, + language, + profile, + ), + AppAction::PlayerStreamsLoaded { + streams, + current_video_id, + initial_stream_index, + saved_url, + saved_title, + source_selection_mode, + regex_pattern, + preferred_binge_group, + } => player::dispatch_streams_loaded( + self, + streams, + current_video_id, + initial_stream_index, + saved_url, + saved_title, + source_selection_mode, + regex_pattern, + preferred_binge_group, + ), + AppAction::PlayerStreamsFailed { error_code } => { + player::dispatch_streams_failed(self, error_code) + } + AppAction::PlayerResolvePlaybackRequested { + url, + stream, + current_video_id, + title, + } => player::dispatch_resolve_playback(self, url, stream, current_video_id, title), + AppAction::ScrobbleRequested { + token, + meta_type, + item_id, + progress, + action_name, + profile, + } => player::dispatch_scrobble( + self, + token, + meta_type, + item_id, + progress, + action_name, + profile, + ), + AppAction::ProfileActivated { profile } => { + library::dispatch_profile_activated(self, profile) + } + AppAction::HomeLoadRequested { + profile, + language, + force, + } => home::dispatch_load(self, profile, language, force), + AppAction::LibraryHydrateRequested { profile_id } => { + library::dispatch_hydrate(self, profile_id) + } + AppAction::ToggleWatchlistRequested { item } => { + library::dispatch_toggle_watchlist(self, item) + } + AppAction::SetFeedbackRequested { id, value, meta } => { + library::dispatch_set_feedback(self, id, value, meta) + } + AppAction::ClearPlaybackProgressRequested { profile, meta } => { + library::dispatch_clear_progress(self, profile, meta) + } + AppAction::SavePlaybackProgressRequested { + profile, + meta, + time_offset, + duration, + last_video_id, + last_stream_index, + last_episode_name, + last_episode_season, + last_episode_number, + last_episode_thumbnail, + last_stream_url, + last_stream_title, + last_audio_language, + last_subtitle_language, + scrobble_trakt_pause, + } => library::dispatch_save_progress( + self, + profile, + meta, + time_offset, + duration, + last_video_id, + last_stream_index, + last_episode_name, + last_episode_season, + last_episode_number, + last_episode_thumbnail, + last_stream_url, + last_stream_title, + last_audio_language, + last_subtitle_language, + scrobble_trakt_pause, + ), + AppAction::MarkWatchedRequested { + series_id, + video_ids, + watched, + meta, + episodes, + profile, + } => library::dispatch_mark_watched( + self, series_id, video_ids, watched, meta, episodes, profile, + ), + AppAction::AddonInstallRequested { + transport_url, + force_refresh, + } => addons::dispatch_install(self, transport_url, force_refresh), + AppAction::AddonsRefreshRequested { + profile, + force_refresh, + } => addons::dispatch_refresh(self, profile, force_refresh), + AppAction::AddonResourceRequested { + transport_url, + resource, + content_type, + id, + extra, + } => addons::dispatch_resource(self, transport_url, resource, content_type, id, extra), + AppAction::SearchRequested { + query, + profile, + language, + } => search::dispatch(self, query, profile, language), + AppAction::DiscoverRequested { + content_type, + filters, + profile, + language, + } => discover::dispatch_discover(self, content_type, filters, profile, language), + AppAction::DiscoverCatalogFiltersRequested { + content_type, + selected_catalog_key, + profile, + language, + } => discover::dispatch_catalog_filters( + self, + content_type, + selected_catalog_key, + profile, + language, + ), + AppAction::CatalogPageRequested { + category_id, + transport_url, + content_type, + catalog_id, + skip, + genre, + search, + } => home::dispatch_catalog_page( + self, + category_id, + transport_url, + content_type, + catalog_id, + skip, + genre, + search, + ), + AppAction::DetailSeasonRequested { + series_id, + season, + profile, + language, + } => detail::dispatch_season(self, series_id, season, profile, language), + AppAction::PlayerNextEpisodeCardShown { + content_type, + series_id, + next_video_id, + title, + original_name, + year, + language, + profile, + } => player::dispatch_next_episode_prefetch( + self, + content_type, + series_id, + next_video_id, + title, + original_name, + year, + language, + profile, + ), + AppAction::SubtitleLoadRequested { + stream, + content_type, + id, + extra_args, + } => player::dispatch_subtitle_load(self, stream, content_type, id, extra_args), + AppAction::ExternalSyncRequested { + provider, + profile, + language, + } => sync::dispatch_external_sync(self, provider, profile, language), + AppAction::AuthFlowRequested { provider, mode } => { + auth::dispatch_flow(self, provider, mode) + } + AppAction::AuthExchangeRequested { + provider, + code, + code_verifier, + profile, + } => auth::dispatch_exchange(self, provider, code, code_verifier, profile), + AppAction::AuthRefreshRequested { provider, profile } => { + auth::dispatch_token_refresh(self, provider, profile) + } + AppAction::ExternalIntegrationSyncRequested { + provider, + profile, + language, + } => sync::dispatch_integration_sync(self, provider, profile, language), + AppAction::SettingsChanged { key, value } => settings::dispatch(self, key, value), + AppAction::CalendarMonthRequested { + profile, + year, + month, + planned_items, + } => calendar::dispatch(self, profile, year, month, planned_items), + AppAction::OfflineDownloadRequested { + meta, + stream, + video_id, + video, + subtitle, + profile_id, + language, + } => offline::dispatch( + self, meta, stream, video_id, video, subtitle, profile_id, language, + ), + } + } + + fn complete_effect(&mut self, result: EffectResultInput) -> Vec { + let effect = match helpers::pending_effect(&self.state, &result.effect_id) { + Some(effect) => effect, + None => return vec![], + }; + let generation = effect["generation"].as_u64().unwrap_or(0); + let effect_type = effect["type"].as_str().unwrap_or_default().to_string(); + helpers::remove_pending_effect(&mut self.state, &result.effect_id); + + match effect_type.as_str() { + "fetchMetaDetail" + | "readPlaybackProgress" + | "readDetailLocalState" + | "fetchDetailSecondary" + | "prefetchDetailStreams" + | "fetchDetailStreams" + | "fetchMetaDetailLookup" + | "fetchSeasonEpisodes" => detail::complete(self, &effect_type, generation, &result), + + "loadStreams" + | "startTorrentStream" + | "enqueueTraktScrobble" + | "stopTorrent" + | "fetchIntroSegments" + | "resolveIntroImdbId" + | "fetchSubtitles" + | "prefetchNextEpisodeStreams" => player::complete(self, &effect_type, generation, &result), + + "readHomeBootstrap" | "prepareDirectPlayback" | "fetchCatalogPage" => { + home::complete(self, &effect_type, generation, &result) + } + + "readLibraryState" + | "writeLibraryCommand" + | "writeFeedback" + | "clearPlaybackProgress" + | "writePlaybackProgress" + | "syncWatchedState" => library::complete(self, &effect_type, generation, &result), + + "fetchAddonManifest" | "refreshInstalledAddons" | "fetchAddonResource" => { + addons::complete(self, &effect_type, generation, &result) + } + + "runSearch" => search::complete(self, generation, &result), + + "runDiscover" | "readDiscoverCatalogFilters" => { + discover::complete(self, &effect_type, generation, &result) + } + + "readCalendarMonth" => calendar::complete(self, generation, &result, &effect), + + "enqueueOfflineDownload" => offline::complete(self, generation, &result), + + "writeSettings" => settings::complete(self, generation, &result), + + "runExternalSync" | "syncExternalIntegration" => { + sync::complete(self, &effect_type, generation, &result) + } + + "runAuthFlow" | "exchangeAuthCode" | "refreshAuthToken" => { + auth::complete(self, &effect_type, generation, &result) + } + + "updateCalendarWidget" + | "notifyReleasedEpisodes" + | "replaceExternalContinueWatching" => { + vec![] + } + + _ => vec![], + } + } + + fn effect(&mut self, kind: EffectKind, generation: u64, payload: Value) -> EffectEnvelope { + self.effect_raw(kind.as_str(), generation, payload) + } + + // For pass-through of effects emitted by sub-modules (e.g. player_flow) where + // the type string is embedded in the JSON at runtime rather than known statically. + fn effect_raw(&mut self, kind: &str, generation: u64, payload: Value) -> EffectEnvelope { + let id = format!("fx-{}", self.next_effect_id); + self.next_effect_id += 1; + let envelope = EffectEnvelope::raw(id, kind, generation, payload); + if let Ok(as_value) = serde_json::to_value(&envelope) { + if let Some(arr) = self.state["pendingEffects"].as_array_mut() { + arr.push(as_value); + } + } + envelope + } + + fn bump_generation(&mut self, key: &str) -> u64 { + let next = helpers::current_generation(&self.state, key).saturating_add(1); + self.state["_runtime"][key] = json!(next); + next + } + + fn result_json(&self, effects: Vec) -> Option { + let visible_effects = if effects.is_empty() { + self.state["pendingEffects"] + .as_array() + .cloned() + .unwrap_or_default() + .into_iter() + .filter_map(|v| serde_json::from_value(v).ok()) + .collect() + } else { + effects + }; + serde_json::to_string(&DispatchResult { + state: self.state.clone(), + effects: visible_effects, + }) + .ok() + } +} + +fn engines() -> &'static Mutex> { + ENGINES.get_or_init(|| Mutex::new(HashMap::new())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detail_load_emits_platform_effects_and_completion_updates_state() { + let handle = create_headless_engine("{}"); + let result: Value = serde_json::from_str( + &headless_engine_dispatch_json( + handle, + r#"{"type":"detailLoadRequested","contentType":"movie","id":"tt1","language":"en"}"#, + ) + .expect("dispatch"), + ) + .expect("json"); + + assert_eq!(result["state"]["detail"]["isLoading"], true); + assert_eq!(result["effects"][0]["type"], "fetchMetaDetail"); + assert_eq!(result["effects"][1]["type"], "readPlaybackProgress"); + + let effect_id = result["effects"][0]["id"].as_str().unwrap(); + let completed: Value = serde_json::from_str( + &headless_engine_complete_effect_json( + handle, + &json!({ + "effectId": effect_id, + "status": "ok", + "value": { "id": "tt1", "name": "Movie" } + }) + .to_string(), + ) + .expect("complete"), + ) + .expect("json"); + + assert_eq!(completed["state"]["detail"]["isLoading"], false); + assert_eq!(completed["state"]["detail"]["meta"]["name"], "Movie"); + assert!(destroy_headless_engine(handle)); + } + + #[test] + fn detail_meta_trailers_are_normalized_in_core_before_tmdb_fallback() { + let handle = create_headless_engine("{}"); + let requested: Value = serde_json::from_str( + &headless_engine_dispatch_json( + handle, + r#"{"type":"detailLoadRequested","contentType":"movie","id":"tt1","language":"en"}"#, + ) + .expect("dispatch"), + ) + .expect("json"); + let effect_id = requested["effects"][0]["id"].as_str().unwrap(); + let completed: Value = serde_json::from_str( + &headless_engine_complete_effect_json( + handle, + &json!({ + "effectId": effect_id, + "status": "ok", + "value": { + "id": "tt1", + "name": "Movie", + "trailers": [ + { "source": "abc123", "type": "Trailer" }, + { "title": "Featurette", "url": "https://video.example/f.mp4", "type": "Clip" } + ] + } + }) + .to_string(), + ) + .expect("complete"), + ) + .expect("json"); + + assert_eq!( + completed["state"]["detail"]["trailers"][0]["url"], + "https://www.youtube.com/watch?v=abc123" + ); + assert_eq!( + completed["state"]["detail"]["trailers"][1]["title"], + "Featurette" + ); + + assert!(destroy_headless_engine(handle)); + } + + #[test] + fn detail_selected_addon_changes_visible_streams_without_mutating_raw_streams() { + let handle = create_headless_engine("{}"); + let requested: Value = serde_json::from_str( + &headless_engine_dispatch_json( + handle, + r#"{"type":"detailStreamsRequested","contentType":"movie","requestIds":["tt1"],"detail":null,"seasonEpisodes":[],"language":"en"}"#, + ) + .expect("dispatch"), + ) + .expect("json"); + let effect_id = requested["effects"][0]["id"].as_str().unwrap(); + let completed: Value = serde_json::from_str( + &headless_engine_complete_effect_json( + handle, + &json!({ + "effectId": effect_id, + "status": "ok", + "value": { + "streams": [ + { "title": "A", "addonName": "One" }, + { "title": "B", "addonName": "Two" }, + { "title": "C", "addonName": "One" } + ], + "availableAddons": ["One", "Two"], + "hasStreamProviders": true + } + }) + .to_string(), + ) + .expect("complete"), + ) + .expect("json"); + assert_eq!(completed["state"]["detail"]["streams"][0]["title"], "A"); + assert_eq!( + completed["state"]["detail"]["visibleStreams"][1]["title"], + "B" + ); + + let selected: Value = serde_json::from_str( + &headless_engine_dispatch_json( + handle, + r#"{"type":"detailSelectedAddonChanged","addon":"one"}"#, + ) + .expect("dispatch"), + ) + .expect("json"); + + assert_eq!( + selected["state"]["detail"]["streams"] + .as_array() + .unwrap() + .len(), + 3 + ); + assert_eq!( + selected["state"]["detail"]["visibleStreams"] + .as_array() + .unwrap() + .len(), + 2 + ); + assert_eq!( + selected["state"]["detail"]["visibleStreams"][0]["title"], + "A" + ); + assert_eq!( + selected["state"]["detail"]["visibleStreams"][1]["title"], + "C" + ); + assert!(destroy_headless_engine(handle)); + } + + #[test] + fn stale_detail_effect_completion_does_not_override_newer_state() { + let handle = create_headless_engine("{}"); + let first: Value = serde_json::from_str( + &headless_engine_dispatch_json( + handle, + r#"{"type":"detailLoadRequested","contentType":"movie","id":"tt1"}"#, + ) + .unwrap(), + ) + .unwrap(); + let stale_effect_id = first["effects"][0]["id"].as_str().unwrap().to_string(); + + headless_engine_dispatch_json( + handle, + r#"{"type":"detailLoadRequested","contentType":"movie","id":"tt2"}"#, + ) + .unwrap(); + + let completed: Value = serde_json::from_str( + &headless_engine_complete_effect_json( + handle, + &json!({ + "effectId": stale_effect_id, + "status": "ok", + "value": { "id": "tt1", "name": "Old" } + }) + .to_string(), + ) + .unwrap(), + ) + .unwrap(); + + assert_eq!(completed["state"]["detail"]["id"], "tt2"); + assert!(completed["state"]["detail"]["meta"].is_null()); + assert!(destroy_headless_engine(handle)); + } + + #[test] + fn player_load_streams_uses_effect_completion_without_reordering_streams() { + let handle = create_headless_engine("{}"); + let requested: Value = serde_json::from_str( + &headless_engine_dispatch_json( + handle, + r#"{"type":"playerLoadStreamsRequested","contentType":"movie","id":"tt1","currentVideoId":"tt1","initialStreamIndex":1}"#, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(requested["effects"][0]["type"], "loadStreams"); + + let effect_id = requested["effects"][0]["id"].as_str().unwrap(); + let completed: Value = serde_json::from_str( + &headless_engine_complete_effect_json( + handle, + &json!({ + "effectId": effect_id, + "status": "ok", + "value": [ + { "title": "A", "playableUrl": "http://a" }, + { "title": "B", "playableUrl": "http://b" } + ] + }) + .to_string(), + ) + .unwrap(), + ) + .unwrap(); + + assert_eq!(completed["state"]["player"]["currentStreamIndex"], 1); + assert_eq!(completed["state"]["player"]["currentUrl"], "http://b"); + assert_eq!( + completed["state"]["player"]["currentStreams"][0]["title"], + "A" + ); + assert!(destroy_headless_engine(handle)); + } + + #[test] + fn player_resolve_playback_emits_torrent_or_direct_platform_effects() { + let handle = create_headless_engine("{}"); + let torrent: Value = serde_json::from_str( + &headless_engine_dispatch_json( + handle, + r#"{"type":"playerResolvePlaybackRequested","url":"stremio://torrent/abc","stream":{"title":"T"},"currentVideoId":"tt1","title":"Movie"}"#, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(torrent["effects"][0]["type"], "startTorrentStream"); + let effect_id = torrent["effects"][0]["id"].as_str().unwrap(); + + let completed: Value = serde_json::from_str( + &headless_engine_complete_effect_json( + handle, + &json!({ + "effectId": effect_id, + "status": "ok", + "value": { "url": "http://127.0.0.1:8090/stream" } + }) + .to_string(), + ) + .unwrap(), + ) + .unwrap(); + assert_eq!( + completed["state"]["player"]["resolvedUrl"], + "http://127.0.0.1:8090/stream" + ); + + let direct: Value = serde_json::from_str( + &headless_engine_dispatch_json( + handle, + r#"{"type":"playerResolvePlaybackRequested","url":"https://video.example/file.mp4","title":"Movie"}"#, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!( + direct["state"]["player"]["resolvedUrl"], + "https://video.example/file.mp4" + ); + assert_eq!(direct["effects"][0]["type"], "stopTorrent"); + assert!(destroy_headless_engine(handle)); + } + + #[test] + fn home_load_is_owned_by_core_and_resolved_through_platform_effect() { + let handle = create_headless_engine("{}"); + let requested: Value = serde_json::from_str( + &headless_engine_dispatch_json( + handle, + r#"{"type":"homeLoadRequested","profile":{"id":"p1"},"language":"tr","force":true}"#, + ) + .unwrap(), + ) + .unwrap(); + + assert_eq!(requested["state"]["home"]["isLoading"], true); + assert_eq!(requested["effects"][0]["type"], "readHomeBootstrap"); + assert_eq!(requested["effects"][0]["payload"]["profileId"], "p1"); + assert_eq!(requested["effects"][0]["payload"]["language"], "tr"); + assert_eq!(requested["effects"][0]["payload"]["force"], true); + + let effect_id = requested["effects"][0]["id"].as_str().unwrap(); + let completed: Value = serde_json::from_str( + &headless_engine_complete_effect_json( + handle, + &json!({ + "effectId": effect_id, + "status": "ok", + "value": { + "categories": [{ "id": "featured" }], + "continueWatching": [{ "id": "tt1" }], + "metadataFeeds": [{ "key": "cinemeta" }], + "billboard": { "id": "tt2" } + } + }) + .to_string(), + ) + .unwrap(), + ) + .unwrap(); + + assert_eq!(completed["state"]["home"]["isLoading"], false); + assert_eq!( + completed["state"]["home"]["categories"][0]["id"], + "featured" + ); + assert_eq!( + completed["state"]["home"]["continueWatching"][0]["id"], + "tt1" + ); + assert_eq!(completed["state"]["home"]["billboard"]["id"], "tt2"); + assert!(destroy_headless_engine(handle)); + } + + #[test] + fn library_commands_are_storage_effects_owned_by_core() { + let handle = create_headless_engine(r#"{"profile":{"activeProfileId":"p1"}}"#); + let requested: Value = serde_json::from_str( + &headless_engine_dispatch_json( + handle, + r#"{"type":"toggleWatchlistRequested","item":{"id":"tt1","name":"Movie","type":"movie"}}"#, + ) + .unwrap(), + ) + .unwrap(); + + assert_eq!(requested["effects"][0]["type"], "writeLibraryCommand"); + assert_eq!(requested["effects"][0]["payload"]["profileId"], "p1"); + assert_eq!( + requested["effects"][0]["payload"]["command"]["type"], + "toggleWatchlist" + ); + assert_eq!( + requested["effects"][0]["payload"]["command"]["item"]["id"], + "tt1" + ); + + let completed: Value = serde_json::from_str( + &headless_engine_complete_effect_json( + handle, + &json!({ + "effectId": requested["effects"][0]["id"].as_str().unwrap(), + "status": "ok", + "value": { "watchlist": [{ "id": "tt1" }] } + }) + .to_string(), + ) + .unwrap(), + ) + .unwrap(); + + assert!(completed["state"]["library"]["lastWriteError"].is_null()); + assert_eq!( + completed["state"]["library"]["lastWrite"]["watchlist"][0]["id"], + "tt1" + ); + assert!(destroy_headless_engine(handle)); + } + + #[test] + fn playback_progress_write_is_clamped_and_delegated_to_storage_adapter() { + let handle = create_headless_engine(r#"{"profile":{"activeProfileId":"p1"}}"#); + let requested: Value = serde_json::from_str( + &headless_engine_dispatch_json( + handle, + r#"{"type":"savePlaybackProgressRequested","meta":{"id":"tt1","name":"Movie","type":"movie"},"timeOffset":-10,"duration":7200,"lastVideoId":"tt1","lastStreamIndex":2,"lastEpisodeName":null,"lastStreamUrl":"http://a","lastStreamTitle":"A","lastAudioLanguage":"en","lastSubtitleLanguage":"tr"}"#, + ) + .unwrap(), + ) + .unwrap(); + + assert_eq!(requested["effects"][0]["type"], "writePlaybackProgress"); + assert_eq!(requested["effects"][0]["payload"]["profileId"], "p1"); + assert_eq!( + requested["effects"][0]["payload"]["progress"]["timeOffset"], + 0 + ); + assert_eq!( + requested["effects"][0]["payload"]["progress"]["lastSubtitleLanguage"], + "tr" + ); + + let completed: Value = serde_json::from_str( + &headless_engine_complete_effect_json( + handle, + &json!({ + "effectId": requested["effects"][0]["id"].as_str().unwrap(), + "status": "ok", + "value": {} + }) + .to_string(), + ) + .unwrap(), + ) + .unwrap(); + + assert!(completed["state"]["library"]["pendingPlaybackProgress"].is_null()); + assert_eq!( + completed["state"]["library"]["savedPlaybackProgress"]["meta"]["id"], + "tt1" + ); + assert!(destroy_headless_engine(handle)); + } + + #[test] + fn effect_completion_returns_remaining_pending_effects_for_adapter_drain() { + let handle = create_headless_engine("{}"); + let requested: Value = serde_json::from_str( + &headless_engine_dispatch_json( + handle, + r#"{"type":"detailLoadRequested","contentType":"movie","id":"tt1","language":"en"}"#, + ) + .unwrap(), + ) + .unwrap(); + + let completed: Value = serde_json::from_str( + &headless_engine_complete_effect_json( + handle, + &json!({ + "effectId": requested["effects"][0]["id"].as_str().unwrap(), + "status": "ok", + "value": { "id": "tt1", "name": "Movie" } + }) + .to_string(), + ) + .unwrap(), + ) + .unwrap(); + + assert_eq!(completed["effects"][0]["type"], "readPlaybackProgress"); + assert!(destroy_headless_engine(handle)); + } + + #[test] + fn addon_search_discover_and_catalog_backbone_are_effect_driven() { + let handle = create_headless_engine(r#"{"profile":{"activeProfileId":"p1"}}"#); + + let addon: Value = serde_json::from_str( + &headless_engine_dispatch_json( + handle, + r#"{"type":"addonInstallRequested","transportUrl":"https://addon.example/manifest.json","forceRefresh":true}"#, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(addon["effects"][0]["type"], "fetchAddonManifest"); + assert_eq!( + addon["effects"][0]["payload"]["transportUrl"], + "https://addon.example/manifest.json" + ); + + let completed_addon: Value = serde_json::from_str( + &headless_engine_complete_effect_json( + handle, + &json!({ + "effectId": addon["effects"][0]["id"].as_str().unwrap(), + "status": "ok", + "value": { + "id": "addon.example", + "transportUrl": "https://addon.example/manifest.json", + "name": "Addon" + } + }) + .to_string(), + ) + .unwrap(), + ) + .unwrap(); + assert_eq!( + completed_addon["state"]["addons"]["installed"][0]["name"], + "Addon" + ); + + let resource: Value = serde_json::from_str( + &headless_engine_dispatch_json( + handle, + r#"{"type":"addonResourceRequested","transportUrl":"https://addon.example/manifest.json","resource":"stream","contentType":"movie","id":"tt1","extra":{"search":"keep order"}}"#, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(resource["effects"][0]["type"], "fetchAddonResource"); + assert_eq!(resource["effects"][0]["payload"]["resource"], "stream"); + assert_eq!( + resource["effects"][0]["payload"]["extra"]["search"], + "keep order" + ); + + let search: Value = serde_json::from_str( + &headless_engine_dispatch_json( + handle, + r#"{"type":"searchRequested","query":"matrix","language":"en"}"#, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(search["effects"][0]["type"], "runSearch"); + assert_eq!(search["effects"][0]["payload"]["profileId"], "p1"); + + let discover: Value = serde_json::from_str( + &headless_engine_dispatch_json( + handle, + r#"{"type":"discoverRequested","contentType":"movie","filters":{"genre":"sci-fi"},"language":"en"}"#, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(discover["effects"][0]["type"], "runDiscover"); + assert_eq!( + discover["effects"][0]["payload"]["filters"]["genre"], + "sci-fi" + ); + + let page: Value = serde_json::from_str( + &headless_engine_dispatch_json( + handle, + r#"{"type":"catalogPageRequested","categoryId":"cat","transportUrl":"https://addon.example/manifest.json","contentType":"movie","catalogId":"top","skip":-10,"genre":null,"search":null}"#, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(page["effects"][0]["type"], "fetchCatalogPage"); + assert_eq!(page["effects"][0]["payload"]["skip"], 0); + assert!(destroy_headless_engine(handle)); + } + + #[test] + fn detail_player_sync_auth_settings_calendar_and_offline_are_core_actions() { + let handle = create_headless_engine(r#"{"profile":{"activeProfileId":"p1"}}"#); + + let season: Value = serde_json::from_str( + &headless_engine_dispatch_json( + handle, + r#"{"type":"detailSeasonRequested","seriesId":"tt1","season":2}"#, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(season["effects"][0]["type"], "fetchSeasonEpisodes"); + + let subtitles: Value = serde_json::from_str( + &headless_engine_dispatch_json( + handle, + r#"{"type":"subtitleLoadRequested","stream":{"url":"http://a"},"contentType":"movie","id":"tt1","extraArgs":"videoHash=abc"}"#, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(subtitles["effects"][0]["type"], "fetchSubtitles"); + assert_eq!( + subtitles["effects"][0]["payload"]["extraArgs"], + "videoHash=abc" + ); + + let sync: Value = serde_json::from_str( + &headless_engine_dispatch_json( + handle, + r#"{"type":"externalSyncRequested","provider":"trakt","language":"tr"}"#, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(sync["effects"][0]["type"], "runExternalSync"); + assert_eq!(sync["effects"][0]["payload"]["profileId"], "p1"); + + let auth: Value = serde_json::from_str( + &headless_engine_dispatch_json( + handle, + r#"{"type":"authFlowRequested","provider":"trakt","mode":"deviceCode"}"#, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(auth["effects"][0]["type"], "runAuthFlow"); + + let settings: Value = serde_json::from_str( + &headless_engine_dispatch_json( + handle, + r#"{"type":"settingsChanged","key":"language","value":"tr"}"#, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(settings["state"]["settings"]["values"]["language"], "tr"); + assert_eq!(settings["effects"][0]["type"], "writeSettings"); + + let calendar: Value = serde_json::from_str( + &headless_engine_dispatch_json( + handle, + r#"{"type":"calendarMonthRequested","year":2026,"month":20}"#, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(calendar["effects"][0]["type"], "readCalendarMonth"); + assert_eq!(calendar["effects"][0]["payload"]["month"], 12); + + let offline: Value = serde_json::from_str( + &headless_engine_dispatch_json( + handle, + r#"{"type":"offlineDownloadRequested","meta":{"id":"tt1"},"stream":{"url":"http://a"},"videoId":"tt1"}"#, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(offline["effects"][0]["type"], "enqueueOfflineDownload"); + assert_eq!(offline["effects"][0]["payload"]["meta"]["id"], "tt1"); + assert!(destroy_headless_engine(handle)); + } + + #[test] + fn calendar_completion_plans_os_side_effects_in_core() { + let handle = create_headless_engine("{}"); + let requested: Value = serde_json::from_str( + &headless_engine_dispatch_json( + handle, + r#"{"type":"calendarMonthRequested","profile":{"id":"p1","language":"tr"},"year":2026,"month":5}"#, + ) + .unwrap(), + ) + .unwrap(); + + let completed: Value = serde_json::from_str( + &headless_engine_complete_effect_json( + handle, + &json!({ + "effectId": requested["effects"][0]["id"].as_str().unwrap(), + "status": "ok", + "value": { + "items": [{ "dateIso": "2026-05-20", "title": "Episode" }], + "localItems": [{ "id": "tt1" }], + "externalItems": [{ "id": "tt2" }] + } + }) + .to_string(), + ) + .unwrap(), + ) + .unwrap(); + + assert_eq!(completed["state"]["calendar"]["isLoading"], false); + assert_eq!( + completed["state"]["calendar"]["items"][0]["title"], + "Episode" + ); + assert_eq!( + completed["effects"] + .as_array() + .unwrap() + .iter() + .map(|effect| effect["type"].as_str().unwrap()) + .collect::>(), + vec![ + "updateCalendarWidget", + "notifyReleasedEpisodes", + "replaceExternalContinueWatching" + ] + ); + assert_eq!(completed["effects"][0]["payload"]["profile"]["id"], "p1"); + assert_eq!(completed["effects"][2]["payload"]["items"][0]["id"], "tt2"); + assert!(destroy_headless_engine(handle)); + } + + #[test] + fn next_episode_card_shown_prefetches_streams_and_load_streams_consumes_cache() { + let handle = create_headless_engine("{}"); + + // 1. Next episode card shown for episode tt1:1:2 + let prefetch_requested: Value = serde_json::from_str( + &headless_engine_dispatch_json( + handle, + r#"{"type":"playerNextEpisodeCardShown","contentType":"series","seriesId":"tt1","nextVideoId":"tt1:1:2","title":"Show","language":"en"}"#, + ) + .unwrap(), + ) + .unwrap(); + + assert_eq!(prefetch_requested["effects"][0]["type"], "prefetchNextEpisodeStreams"); + assert_eq!(prefetch_requested["effects"][0]["payload"]["nextVideoId"], "tt1:1:2"); + assert_eq!(prefetch_requested["state"]["player"]["prefetchingNextVideoId"], "tt1:1:2"); + + // Duplicate card-shown dispatch must not change prefetching state. + let duplicate: Value = serde_json::from_str( + &headless_engine_dispatch_json( + handle, + r#"{"type":"playerNextEpisodeCardShown","contentType":"series","seriesId":"tt1","nextVideoId":"tt1:1:2"}"#, + ) + .unwrap(), + ) + .unwrap(); + // Guard works: prefetchingNextVideoId unchanged, no new prefetch effect was queued. + assert_eq!(duplicate["state"]["player"]["prefetchingNextVideoId"], "tt1:1:2"); + + // 2. Platform completes the prefetch with streams for tt1:1:2 + let effect_id = prefetch_requested["effects"][0]["id"].as_str().unwrap(); + let prefetch_done: Value = serde_json::from_str( + &headless_engine_complete_effect_json( + handle, + &json!({ + "effectId": effect_id, + "status": "ok", + "value": { + "streams": [ + { "title": "S", "playableUrl": "http://ep2" } + ] + } + }) + .to_string(), + ) + .unwrap(), + ) + .unwrap(); + + assert_eq!(prefetch_done["state"]["player"]["prefetchedNextEpisode"]["videoId"], "tt1:1:2"); + assert_eq!(prefetch_done["state"]["player"]["prefetchedNextEpisode"]["streams"][0]["title"], "S"); + assert!(prefetch_done["state"]["player"]["prefetchingNextVideoId"].is_null()); + + // 3. User navigates to ep2 — load streams without passing initial_streams. + // Core must inject the prefetched streams and use_initial_streams = true. + let load: Value = serde_json::from_str( + &headless_engine_dispatch_json( + handle, + r#"{"type":"playerLoadStreamsRequested","contentType":"series","id":"tt1","currentVideoId":"tt1:1:2"}"#, + ) + .unwrap(), + ) + .unwrap(); + + assert_eq!(load["effects"][0]["type"], "loadStreams"); + // useInitialStreams = true means the platform skips the network fetch + assert_eq!(load["effects"][0]["payload"]["useInitialStreams"], true); + // Cache must be consumed (cleared) after use + assert!(load["state"]["player"]["prefetchedNextEpisode"].is_null()); + + assert!(destroy_headless_engine(handle)); + } +} diff --git a/src/headless_engine/navigation.rs b/src/headless_engine/navigation.rs new file mode 100644 index 0000000..cb81e6f --- /dev/null +++ b/src/headless_engine/navigation.rs @@ -0,0 +1,11 @@ +use super::HeadlessEngine; +use crate::runtime::EffectEnvelope; +use serde_json::{json, Value}; + +pub(super) fn dispatch(engine: &mut HeadlessEngine, route: String, params: Option) -> Vec { + engine.state["navigation"] = json!({ + "route": route, + "params": params.unwrap_or(Value::Null) + }); + vec![] +} diff --git a/src/headless_engine/offline.rs b/src/headless_engine/offline.rs new file mode 100644 index 0000000..78a1b23 --- /dev/null +++ b/src/headless_engine/offline.rs @@ -0,0 +1,39 @@ +use super::helpers::{current_generation, normalize_error}; +use super::{EffectResultInput, HeadlessEngine}; +use crate::runtime::{EffectEnvelope, EffectKind}; +use serde_json::{json, Value}; + +pub(super) fn dispatch( + engine: &mut HeadlessEngine, + meta: Value, + stream: Value, + video_id: Option, + video: Option, + subtitle: Option, + profile_id: Option, + language: Option, +) -> Vec { + let generation = engine.bump_generation("offlineGeneration"); + engine.state["offline"]["lastRequest"] = json!({ + "meta": meta, + "stream": stream, + "videoId": video_id, + "video": video, + "subtitle": subtitle, + "profileId": profile_id, + "language": language + }); + vec![engine.effect(EffectKind::EnqueueOfflineDownload, generation, engine.state["offline"]["lastRequest"].clone())] +} + +pub(super) fn complete(engine: &mut HeadlessEngine, generation: u64, result: &EffectResultInput) -> Vec { + if generation == current_generation(&engine.state, "offlineGeneration") { + if result.status == "ok" { + engine.state["offline"]["lastEnqueued"] = result.value.clone(); + engine.state["offline"]["error"] = Value::Null; + } else { + engine.state["offline"]["error"] = normalize_error(result.error.clone()); + } + } + vec![] +} diff --git a/src/headless_engine/player.rs b/src/headless_engine/player.rs new file mode 100644 index 0000000..53711df --- /dev/null +++ b/src/headless_engine/player.rs @@ -0,0 +1,453 @@ +use super::helpers::{current_generation, error_code, normalize_error}; +use super::{EffectResultInput, HeadlessEngine}; +use crate::runtime::{EffectEnvelope, EffectKind}; +use crate::{player_flow, stream_policy}; +use serde_json::{json, Value}; + +#[allow(clippy::too_many_arguments)] +pub(super) fn dispatch_next_episode_prefetch( + engine: &mut HeadlessEngine, + content_type: String, + series_id: String, + next_video_id: String, + title: Option, + original_name: Option, + year: Option, + language: Option, + profile: Option, +) -> Vec { + let already_prefetching = engine.state["player"]["prefetchingNextVideoId"] + .as_str() + .is_some_and(|v| v == next_video_id); + let already_cached = engine.state["player"]["prefetchedNextEpisode"]["videoId"] + .as_str() + .is_some_and(|v| v == next_video_id); + if already_prefetching || already_cached { + return vec![]; + } + + let generation = current_generation(&engine.state, "playerGeneration"); + engine.state["player"]["prefetchingNextVideoId"] = json!(next_video_id); + vec![engine.effect( + EffectKind::PrefetchNextEpisodeStreams, + generation, + json!({ + "contentType": content_type, + "seriesId": series_id, + "nextVideoId": next_video_id, + "title": title.unwrap_or_default(), + "originalName": original_name, + "year": year, + "language": language.unwrap_or_else(|| "en".to_string()), + "profile": profile.unwrap_or(Value::Null) + }), + )] +} + +#[allow(clippy::too_many_arguments)] +pub(super) fn dispatch_load_streams( + engine: &mut HeadlessEngine, + content_type: String, + id: String, + current_video_id: Option, + initial_video_id: Option, + initial_streams: Option>, + initial_stream_index: Option, + saved_url: Option, + saved_title: Option, + source_selection_mode: Option, + regex_pattern: Option, + preferred_binge_group: Option, + title: Option, + original_name: Option, + year: Option, + language: Option, + profile: Option, +) -> Vec { + let generation = engine.bump_generation("playerGeneration"); + let mut initial_streams = initial_streams.unwrap_or_default(); + let initial_stream_index = initial_stream_index.unwrap_or(0); + + // If no initial streams were provided by the caller but we have a prefetch + // cache hit for this video_id, inject those streams so playback can start + // without waiting for a fresh fetch. + let mut effective_initial_video_id = initial_video_id.clone(); + if initial_streams.is_empty() { + let prefetched = &engine.state["player"]["prefetchedNextEpisode"]; + let cached_video_id = prefetched["videoId"].as_str().map(str::to_string); + if cached_video_id.is_some() && cached_video_id == current_video_id { + initial_streams = prefetched["streams"] + .as_array() + .cloned() + .unwrap_or_default(); + effective_initial_video_id = cached_video_id; + engine.state["player"]["prefetchedNextEpisode"] = Value::Null; + } + } + let player_flow_state = engine.state.get("player").cloned().unwrap_or_else(|| json!({})); + let Some(flow) = player_flow::player_flow_dispatch_json( + &player_flow_state.to_string(), + &json!({ + "type": "loadStreamsRequested", + "contentType": content_type, + "id": id, + "currentVideoId": current_video_id, + "initialVideoId": effective_initial_video_id, + "initialStreams": initial_streams, + "initialStreamIndex": initial_stream_index + }) + .to_string(), + ) else { + engine.state["player"]["playerError"] = json!("invalid_flow_action"); + return vec![]; + }; + let Ok(flow) = serde_json::from_str::(&flow) else { + engine.state["player"]["playerError"] = json!("invalid_flow_result"); + return vec![]; + }; + engine.state["player"] = flow["state"].clone(); + engine.state["player"]["pendingStreamLoad"] = json!({ + "savedUrl": saved_url, + "savedTitle": saved_title, + "sourceSelectionMode": source_selection_mode.unwrap_or_else(|| "manual".to_string()), + "regexPattern": regex_pattern, + "preferredBingeGroup": preferred_binge_group, + "initialStreams": initial_streams, + "initialStreamIndex": initial_stream_index, + "currentVideoId": current_video_id, + "title": title, + "originalName": original_name, + "year": year, + "language": language.unwrap_or_else(|| "en".to_string()), + "profile": profile.unwrap_or(Value::Null) + }); + flow["effects"] + .as_array() + .into_iter() + .flatten() + .map(|effect| { + let mut payload = effect.clone(); + if effect["type"].as_str() == Some("loadStreams") { + payload["initialStreams"] = + engine.state["player"]["pendingStreamLoad"]["initialStreams"].clone(); + payload["title"] = + engine.state["player"]["pendingStreamLoad"]["title"].clone(); + payload["originalName"] = + engine.state["player"]["pendingStreamLoad"]["originalName"].clone(); + payload["year"] = + engine.state["player"]["pendingStreamLoad"]["year"].clone(); + payload["language"] = + engine.state["player"]["pendingStreamLoad"]["language"].clone(); + payload["profile"] = + engine.state["player"]["pendingStreamLoad"]["profile"].clone(); + } + engine.effect_raw(effect["type"].as_str().unwrap_or("unknown"), generation, payload) + }) + .collect() +} + +pub(super) fn dispatch_streams_loaded( + engine: &mut HeadlessEngine, + streams: Vec, + current_video_id: Option, + initial_stream_index: Option, + saved_url: Option, + saved_title: Option, + source_selection_mode: Option, + regex_pattern: Option, + preferred_binge_group: Option, +) -> Vec { + let generation = current_generation(&engine.state, "playerGeneration"); + let player_flow_state = engine.state.get("player").cloned().unwrap_or_else(|| json!({})); + let Some(flow) = player_flow::player_flow_dispatch_json( + &player_flow_state.to_string(), + &json!({ + "type": "streamsLoaded", + "streams": streams, + "currentVideoId": current_video_id, + "initialStreamIndex": initial_stream_index.unwrap_or(0), + "savedUrl": saved_url, + "savedTitle": saved_title, + "sourceSelectionMode": source_selection_mode, + "regexPattern": regex_pattern, + "preferredBingeGroup": preferred_binge_group + }) + .to_string(), + ) else { + engine.state["player"]["playerError"] = json!("invalid_flow_action"); + return vec![]; + }; + let Ok(flow) = serde_json::from_str::(&flow) else { + engine.state["player"]["playerError"] = json!("invalid_flow_result"); + return vec![]; + }; + engine.state["player"] = flow["state"].clone(); + engine.state["player"]["generation"] = json!(generation); + vec![] +} + +pub(super) fn dispatch_streams_failed( + engine: &mut HeadlessEngine, + err_code: Option, +) -> Vec { + let player_flow_state = engine.state.get("player").cloned().unwrap_or_else(|| json!({})); + let Some(flow) = player_flow::player_flow_dispatch_json( + &player_flow_state.to_string(), + &json!({ "type": "streamsFailed", "errorCode": err_code }).to_string(), + ) else { + engine.state["player"]["playerError"] = json!("invalid_flow_action"); + return vec![]; + }; + let Ok(flow) = serde_json::from_str::(&flow) else { + engine.state["player"]["playerError"] = json!("invalid_flow_result"); + return vec![]; + }; + engine.state["player"] = flow["state"].clone(); + vec![] +} + +pub(super) fn dispatch_resolve_playback( + engine: &mut HeadlessEngine, + url: String, + stream: Option, + current_video_id: Option, + title: Option, +) -> Vec { + let generation = engine.bump_generation("playerGeneration"); + engine.state["player"]["currentUrl"] = json!(url); + engine.state["player"]["resolvedUrl"] = Value::Null; + engine.state["player"]["isBuffering"] = json!(true); + engine.state["player"]["hasStartedPlaying"] = json!(false); + engine.state["player"]["isVideoRendered"] = json!(false); + engine.state["player"]["playerError"] = Value::Null; + if stream_policy::is_torrent_playback_url(&url) { + let stream_value = stream.unwrap_or(Value::Null); + let file_idx = stream_value["fileIdx"].as_i64(); + let preferred_filename = stream_value["effectiveFilename"] + .as_str() + .map(ToString::to_string); + vec![engine.effect( + EffectKind::StartTorrentStream, + generation, + json!({ + "url": url, + "stream": stream_value.clone(), + "currentVideoId": current_video_id, + "title": title.unwrap_or_else(|| "Fluxa".to_string()), + "fileIdx": file_idx, + "preferredFilename": preferred_filename, + "sources": stream_value["sources"].as_array().cloned().unwrap_or_default() + }), + )] + } else { + engine.state["player"]["resolvedUrl"] = engine.state["player"]["currentUrl"].clone(); + engine.state["player"]["isBuffering"] = json!(false); + vec![engine.effect( + EffectKind::StopTorrent, + generation, + json!({ "reason": "directPlayback" }), + )] + } +} + +pub(super) fn dispatch_scrobble( + engine: &mut HeadlessEngine, + token: String, + meta_type: String, + item_id: String, + progress: f64, + action_name: String, + profile: Option, +) -> Vec { + let generation = engine.bump_generation("playerGeneration"); + vec![engine.effect( + EffectKind::EnqueueTraktScrobble, + generation, + json!({ + "token": token, + "metaType": meta_type, + "itemId": item_id, + "progress": progress, + "actionName": action_name, + "profile": profile.unwrap_or(Value::Null) + }), + )] +} + +pub(super) fn dispatch_intro_segments( + engine: &mut HeadlessEngine, + imdb_id: String, + season: i32, + episode: i32, + title: Option, + use_intro_db: bool, + use_ani_skip: bool, +) -> Vec { + let generation = engine.bump_generation("introGeneration"); + vec![engine.effect( + EffectKind::FetchIntroSegments, + generation, + json!({ + "imdbId": imdb_id, + "season": season, + "episode": episode, + "title": title, + "useIntroDb": use_intro_db, + "useAniSkip": use_ani_skip + }), + )] +} + +pub(super) fn dispatch_intro_imdb_id( + engine: &mut HeadlessEngine, + meta: Value, + video_id: Option, + language: Option, +) -> Vec { + let generation = engine.bump_generation("introGeneration"); + vec![engine.effect( + EffectKind::ResolveIntroImdbId, + generation, + json!({ + "meta": meta, + "videoId": video_id, + "language": language.unwrap_or_else(|| "en".to_string()) + }), + )] +} + +pub(super) fn dispatch_subtitle_load( + engine: &mut HeadlessEngine, + stream: Value, + content_type: String, + id: String, + extra_args: Option, +) -> Vec { + let generation = engine.bump_generation("playerGeneration"); + engine.state["player"]["subtitleLoading"] = json!(true); + vec![engine.effect( + EffectKind::FetchSubtitles, + generation, + json!({ + "stream": stream, + "contentType": content_type, + "id": id, + "extraArgs": extra_args.unwrap_or_default() + }), + )] +} + +pub(super) fn complete( + engine: &mut HeadlessEngine, + effect_type: &str, + generation: u64, + result: &EffectResultInput, +) -> Vec { + match effect_type { + "loadStreams" => { + if generation == current_generation(&engine.state, "playerGeneration") { + let pending = engine.state["player"]["pendingStreamLoad"].clone(); + if result.status == "ok" { + dispatch_streams_loaded( + engine, + result.value.as_array().cloned().unwrap_or_default(), + pending["currentVideoId"].as_str().map(ToString::to_string), + Some(pending["initialStreamIndex"].as_i64().unwrap_or(0) as i32), + pending["savedUrl"].as_str().map(ToString::to_string), + pending["savedTitle"].as_str().map(ToString::to_string), + pending["sourceSelectionMode"].as_str().map(ToString::to_string), + pending["regexPattern"].as_str().map(ToString::to_string), + pending["preferredBingeGroup"].as_str().map(ToString::to_string), + ); + } else { + dispatch_streams_failed(engine, Some(error_code(&result.error))); + } + engine.state["player"]["pendingStreamLoad"] = Value::Null; + } + } + "startTorrentStream" => { + if generation == current_generation(&engine.state, "playerGeneration") { + if result.status == "ok" { + engine.state["player"]["resolvedUrl"] = + result.value.get("url").cloned().unwrap_or(Value::Null); + engine.state["player"]["isBuffering"] = json!(false); + engine.state["player"]["playerError"] = Value::Null; + } else { + engine.state["player"]["resolvedUrl"] = Value::Null; + engine.state["player"]["isBuffering"] = json!(false); + engine.state["player"]["playerError"] = json!(error_code(&result.error)); + } + } + } + "enqueueTraktScrobble" => { + if generation == current_generation(&engine.state, "playerGeneration") { + if result.status == "ok" { + engine.state["player"]["lastScrobble"] = result.value.clone(); + engine.state["player"]["playerError"] = Value::Null; + } else { + engine.state["player"]["playerError"] = json!(error_code(&result.error)); + } + } + } + "stopTorrent" => { + if generation == current_generation(&engine.state, "playerGeneration") + && result.status != "ok" + { + engine.state["player"]["stopTorrentWarning"] = + normalize_error(result.error.clone()); + } + } + "fetchIntroSegments" => { + if generation == current_generation(&engine.state, "introGeneration") { + if result.status == "ok" { + engine.state["player"]["introSegments"] = result.value.clone(); + engine.state["player"]["playerError"] = Value::Null; + } else { + engine.state["player"]["introSegments"] = json!([]); + engine.state["player"]["playerError"] = json!(error_code(&result.error)); + } + } + } + "resolveIntroImdbId" => { + if generation == current_generation(&engine.state, "introGeneration") { + if result.status == "ok" { + engine.state["player"]["introImdbId"] = result.value.clone(); + engine.state["player"]["playerError"] = Value::Null; + } else { + engine.state["player"]["introImdbId"] = Value::Null; + engine.state["player"]["playerError"] = json!(error_code(&result.error)); + } + } + } + "fetchSubtitles" => { + if generation == current_generation(&engine.state, "playerGeneration") { + engine.state["player"]["subtitleLoading"] = json!(false); + if result.status == "ok" { + engine.state["player"]["subtitles"] = result + .value + .get("subtitles") + .cloned() + .unwrap_or_else(|| result.value.clone()); + engine.state["player"]["playerError"] = Value::Null; + } else { + engine.state["player"]["playerError"] = json!(error_code(&result.error)); + } + } + } + "prefetchNextEpisodeStreams" => { + // Only accept if the prefetch generation is still current (player hasn't moved on). + if generation == current_generation(&engine.state, "playerGeneration") { + if result.status == "ok" { + let prefetched_video_id = engine.state["player"]["prefetchingNextVideoId"].clone(); + engine.state["player"]["prefetchedNextEpisode"] = json!({ + "videoId": prefetched_video_id, + "streams": result.value.get("streams").cloned().unwrap_or_else(|| json!([])) + }); + } + engine.state["player"]["prefetchingNextVideoId"] = Value::Null; + } + } + _ => {} + } + vec![] +} diff --git a/src/headless_engine/search.rs b/src/headless_engine/search.rs new file mode 100644 index 0000000..f8b84b7 --- /dev/null +++ b/src/headless_engine/search.rs @@ -0,0 +1,55 @@ +use super::helpers::{active_profile_id, current_generation, normalize_error}; +use super::{EffectResultInput, HeadlessEngine}; +use crate::runtime::{EffectEnvelope, EffectKind}; +use serde_json::{json, Value}; + +pub(super) fn dispatch( + engine: &mut HeadlessEngine, + query: String, + profile: Option, + language: Option, +) -> Vec { + let generation = engine.bump_generation("searchGeneration"); + let profile_value = profile.unwrap_or_else(|| engine.state["profile"]["active"].clone()); + engine.state["search"] = json!({ + "query": query, + "isLoading": true, + "results": [], + "error": Value::Null, + "generation": generation + }); + vec![engine.effect( + EffectKind::RunSearch, + generation, + json!({ + "query": engine.state["search"]["query"].clone(), + "profileId": active_profile_id(&engine.state, &profile_value), + "profile": profile_value, + "language": language.unwrap_or_else(|| "en".to_string()) + }), + )] +} + +pub(super) fn complete(engine: &mut HeadlessEngine, generation: u64, result: &EffectResultInput) -> Vec { + if generation == current_generation(&engine.state, "searchGeneration") { + engine.state["search"]["isLoading"] = json!(false); + if result.status == "ok" { + engine.state["search"]["results"] = result + .value + .get("results") + .cloned() + .unwrap_or_else(|| result.value.clone()); + engine.state["search"]["categories"] = result + .value + .get("categories") + .cloned() + .unwrap_or_else(|| json!([])); + engine.state["search"]["grouping"] = + result.value.get("grouping").cloned().unwrap_or(Value::Null); + engine.state["search"]["error"] = Value::Null; + } else { + engine.state["search"]["error"] = normalize_error(result.error.clone()); + } + } + vec![] +} diff --git a/src/headless_engine/settings.rs b/src/headless_engine/settings.rs new file mode 100644 index 0000000..0e45e87 --- /dev/null +++ b/src/headless_engine/settings.rs @@ -0,0 +1,21 @@ +use super::helpers::{current_generation, normalize_error}; +use super::{EffectResultInput, HeadlessEngine}; +use crate::runtime::{EffectEnvelope, EffectKind}; +use serde_json::{json, Value}; + +pub(super) fn dispatch(engine: &mut HeadlessEngine, key: String, value: Value) -> Vec { + let generation = engine.bump_generation("settingsGeneration"); + engine.state["settings"]["values"][key.as_str()] = value.clone(); + vec![engine.effect(EffectKind::WriteSettings, generation, json!({ "key": key, "value": value }))] +} + +pub(super) fn complete(engine: &mut HeadlessEngine, generation: u64, result: &EffectResultInput) -> Vec { + if generation == current_generation(&engine.state, "settingsGeneration") { + if result.status != "ok" { + engine.state["settings"]["lastWriteError"] = normalize_error(result.error.clone()); + } else { + engine.state["settings"]["lastWriteError"] = Value::Null; + } + } + vec![] +} diff --git a/src/headless_engine/state.rs b/src/headless_engine/state.rs new file mode 100644 index 0000000..92c22c5 --- /dev/null +++ b/src/headless_engine/state.rs @@ -0,0 +1,40 @@ +use serde_json::{json, Value}; + +pub(super) fn default_state() -> Value { + json!({ + "navigation": { "route": "home", "params": Value::Null }, + "home": {}, + "search": {}, + "discover": {}, + "detail": {}, + "player": {}, + "library": {}, + "profile": {}, + "settings": {}, + "calendar": {}, + "addons": { "installed": [] }, + "auth": {}, + "sync": {}, + "lookup": {}, + "offline": {}, + "pendingEffects": [], + "_runtime": { + "detailGeneration": 0, + "playerGeneration": 0, + "homeGeneration": 0, + "libraryGeneration": 0, + "addonGeneration": 0, + "searchGeneration": 0, + "discoverGeneration": 0, + "syncGeneration": 0, + "authGeneration": 0, + "settingsGeneration": 0, + "calendarGeneration": 0, + "offlineGeneration": 0, + "detailStreamsGeneration": 0, + "lookupGeneration": 0, + "playbackPrepGeneration": 0, + "introGeneration": 0 + } + }) +} diff --git a/src/headless_engine/sync.rs b/src/headless_engine/sync.rs new file mode 100644 index 0000000..b4f1cc2 --- /dev/null +++ b/src/headless_engine/sync.rs @@ -0,0 +1,100 @@ +use super::helpers::{active_profile_id, current_generation, normalize_error}; +use super::{EffectResultInput, HeadlessEngine}; +use crate::runtime::{EffectEnvelope, EffectKind}; +use serde_json::{json, Value}; + +pub(super) fn dispatch_external_sync( + engine: &mut HeadlessEngine, + provider: String, + profile: Option, + language: Option, +) -> Vec { + let generation = engine.bump_generation("syncGeneration"); + let profile_value = profile.unwrap_or_else(|| engine.state["profile"]["active"].clone()); + engine.state["sync"] = json!({ + "provider": provider, + "isLoading": true, + "error": Value::Null, + "generation": generation + }); + vec![engine.effect( + EffectKind::RunExternalSync, + generation, + json!({ + "provider": engine.state["sync"]["provider"].clone(), + "profileId": active_profile_id(&engine.state, &profile_value), + "profile": profile_value, + "language": language.unwrap_or_else(|| "en".to_string()) + }), + )] +} + +pub(super) fn dispatch_integration_sync( + engine: &mut HeadlessEngine, + provider: String, + profile: Value, + language: Option, +) -> Vec { + let generation = engine.bump_generation("syncGeneration"); + engine.state["sync"] = json!({ + "provider": provider, + "isLoading": true, + "error": Value::Null, + "generation": generation + }); + vec![engine.effect( + EffectKind::SyncExternalIntegration, + generation, + json!({ + "provider": engine.state["sync"]["provider"].clone(), + "profile": profile, + "language": language.unwrap_or_else(|| "en".to_string()) + }), + )] +} + +pub(super) fn complete( + engine: &mut HeadlessEngine, + effect_type: &str, + generation: u64, + result: &EffectResultInput, +) -> Vec { + match effect_type { + "runExternalSync" => { + if generation == current_generation(&engine.state, "syncGeneration") { + engine.state["sync"]["isLoading"] = json!(false); + if result.status == "ok" { + engine.state["sync"]["snapshot"] = result.value.clone(); + engine.state["sync"]["error"] = Value::Null; + } else { + engine.state["sync"]["error"] = normalize_error(result.error.clone()); + } + } + } + "syncExternalIntegration" => { + if generation == current_generation(&engine.state, "syncGeneration") { + engine.state["sync"]["isLoading"] = json!(false); + if result.status == "ok" { + let updated_profile = + result.value.get("profile").cloned().unwrap_or(Value::Null); + engine.state["sync"]["snapshot"] = + result.value.get("snapshot").cloned().unwrap_or(Value::Null); + if !updated_profile.is_null() { + engine.state["profile"]["active"] = updated_profile.clone(); + engine.state["home"]["activeProfile"] = updated_profile; + } + engine.state["home"]["externalContinueWatching"] = result + .value + .get("externalContinueWatching") + .cloned() + .unwrap_or_else(|| json!([])); + engine.state["sync"]["error"] = Value::Null; + } else { + engine.state["sync"]["error"] = normalize_error(result.error.clone()); + } + } + } + _ => {} + } + vec![] +} diff --git a/src/home_ranking.rs b/src/home_ranking.rs new file mode 100644 index 0000000..2c76eab --- /dev/null +++ b/src/home_ranking.rs @@ -0,0 +1,847 @@ +use crate::content_identity::{imdb_id, normalized_billboard_title}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::collections::{HashMap, HashSet}; + +const CORE_SHELF_KEYS: &[&str] = &[ + "action", + "adventure", + "aksiyon", + "macera", + "sci fi", + "science fiction", + "bilim kurgu", + "fantasy", + "fantastik", + "thriller", + "gerilim", + "crime", + "suc", + "comedy", + "komedi", + "drama", + "dram", + "family", + "aile", + "kids", + "cocuk", + "anime", + "mini series", + "mini dizi", +]; + +#[derive(Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct NativeHomeCategory { + name: String, + items: Vec, + id: String, + #[serde(rename = "type")] + content_type: String, + semantic_name: Option, + movie_genre: Option, + series_genre: Option, + skip: Option, + can_load_more: Option, + catalog_id: Option, + addon_transport_url: Option, + addon_genre: Option, + catalog_sources: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct HomeOptimizeRequest { + categories: Vec, + preferred_order_labels: Vec, + preferred_genres: HashMap, + preferred_types: HashMap, + priority_labels: HomePriorityLabels, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct HomePriorityLabels { + trending_now: String, + popular_for_you: String, + most_watched: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct EditorialPickSpec { + title: String, + min_year: i32, +} + +fn meta_text<'a>(meta: &'a Value, key: &str) -> &'a str { + meta.get(key).and_then(Value::as_str).unwrap_or("") +} + +fn meta_i64(meta: &Value, key: &str) -> Option { + meta.get(key).and_then(Value::as_i64) +} + +fn meta_string_array(meta: &Value, key: &str) -> Vec { + meta.get(key) + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .filter_map(Value::as_str) + .map(str::to_string) + .collect() + }) + .unwrap_or_default() +} + +fn category_semantic_name(category: &NativeHomeCategory) -> &str { + category + .semantic_name + .as_deref() + .filter(|value| !value.is_empty()) + .unwrap_or(&category.name) +} + +pub(crate) fn normalize_home_key(value: &str) -> String { + let mut output = String::with_capacity(value.len()); + let mut last_space = false; + for ch in value.to_lowercase().chars() { + let normalized = match ch { + 'ç' => 'c', + 'ğ' => 'g', + 'ı' => 'i', + 'ö' => 'o', + 'ş' => 's', + 'ü' => 'u', + ch if ch.is_ascii_alphanumeric() => ch, + _ => ' ', + }; + if normalized == ' ' { + if !last_space { + output.push(' '); + last_space = true; + } + } else { + output.push(normalized); + last_space = false; + } + } + output.trim().to_string() +} + +fn semantic_score(category: &NativeHomeCategory, item: &Value) -> i32 { + let category_keys = [ + Some(category.name.as_str()), + Some(category_semantic_name(category)), + category.addon_genre.as_deref(), + ] + .into_iter() + .flatten() + .map(normalize_home_key) + .filter(|value| !value.is_empty()) + .collect::>(); + let genre_score = meta_string_array(item, "genres") + .into_iter() + .map(|genre| normalize_home_key(&genre)) + .filter(|genre| { + category_keys + .iter() + .any(|key| key == genre || key.contains(genre) || genre.contains(key)) + }) + .count() as i32 + * 4; + let title_score = [meta_text(item, "name"), meta_text(item, "originalName")] + .into_iter() + .map(normalize_home_key) + .filter(|title| { + category_keys + .iter() + .any(|key| !key.is_empty() && title.contains(key)) + }) + .count() as i32 + * 2; + genre_score + title_score +} + +fn curated_items(category: &NativeHomeCategory) -> Vec { + let mut values = category + .items + .iter() + .map(|item| (item.clone(), semantic_score(category, item))) + .collect::>(); + values.sort_by(|(left, left_score), (right, right_score)| { + right_score + .cmp(left_score) + .then_with(|| { + meta_i64(left, "rank") + .unwrap_or(i64::MAX) + .cmp(&meta_i64(right, "rank").unwrap_or(i64::MAX)) + }) + .then_with(|| { + meta_text(right, "imdbRating") + .parse::() + .unwrap_or(0.0) + .partial_cmp(&meta_text(left, "imdbRating").parse::().unwrap_or(0.0)) + .unwrap_or(std::cmp::Ordering::Equal) + }) + }); + let mut seen = HashSet::new(); + values + .into_iter() + .filter_map(|(item, _)| { + let id = meta_text(&item, "id").to_string(); + if seen.insert(id) { + Some(item) + } else { + None + } + }) + .take(24) + .collect() +} + +pub(crate) fn curate_home_items_json(category_json: &str) -> Option { + let category = serde_json::from_str::(category_json).ok()?; + serde_json::to_string(&curated_items(&category)).ok() +} + +fn is_pinned(category: &NativeHomeCategory) -> bool { + category.id == "library" + || category.id == "watchlist" + || category.id == "continue_watching" + || category.content_type == "collection" + || category.content_type == "collection_folder" +} + +fn priority_boost(category: &NativeHomeCategory, labels: &HomePriorityLabels) -> i32 { + let key = normalize_home_key(category_semantic_name(category)); + if key.contains(&normalize_home_key(&labels.trending_now)) { + 40 + } else if key.contains(&normalize_home_key(&labels.popular_for_you)) { + 32 + } else if key.contains(&normalize_home_key(&labels.most_watched)) { + 28 + } else if key.contains("new") || key.contains("yeni") { + 16 + } else { + 0 + } +} + +fn personalization_score( + category: &NativeHomeCategory, + preferred_genres: &HashMap, + preferred_types: &HashMap, + labels: &HomePriorityLabels, +) -> i32 { + let type_affinity = category + .items + .iter() + .map(|item| { + preferred_types + .get(meta_text(item, "type")) + .copied() + .unwrap_or(0) + }) + .sum::() + * 12; + let genre_affinity = category + .items + .iter() + .flat_map(|item| meta_string_array(item, "genres")) + .map(|genre| { + preferred_genres + .get(&normalize_home_key(&genre)) + .copied() + .unwrap_or(0) + }) + .sum::() + * 10; + let unique_top_items = category + .items + .iter() + .take(10) + .map(|item| meta_text(item, "id").to_string()) + .collect::>() + .len() as i32 + * 8; + let reason_boost = category + .items + .iter() + .filter(|item| !meta_text(item, "reason").is_empty()) + .count() as i32 + * 14; + type_affinity + + genre_affinity + + unique_top_items + + reason_boost + + priority_boost(category, labels) +} + +fn overlap_ratio(first: &NativeHomeCategory, second: &NativeHomeCategory) -> f32 { + let first_ids = first + .items + .iter() + .take(12) + .map(|item| meta_text(item, "id").to_string()) + .collect::>(); + let second_ids = second + .items + .iter() + .take(12) + .map(|item| meta_text(item, "id").to_string()) + .collect::>(); + if first_ids.is_empty() || second_ids.is_empty() { + return 0.0; + } + first_ids.intersection(&second_ids).count() as f32 + / first_ids.len().min(second_ids.len()) as f32 +} + +pub(crate) fn home_overlap_ratio_json(first_json: &str, second_json: &str) -> Option { + let first = serde_json::from_str::(first_json).ok()?; + let second = serde_json::from_str::(second_json).ok()?; + Some(overlap_ratio(&first, &second)) +} + +fn is_core_genre_shelf(category: &NativeHomeCategory) -> bool { + if category.movie_genre.is_some() + || category.series_genre.is_some() + || category.addon_genre.is_some() + { + return true; + } + let key = normalize_home_key(category_semantic_name(category)); + CORE_SHELF_KEYS + .iter() + .any(|candidate| key == *candidate || key.contains(candidate)) +} + +fn cluster_key(category: &NativeHomeCategory) -> Option { + if let Some(genre) = category.movie_genre.as_deref() { + return Some(format!("movie:{}", normalize_home_key(genre))); + } + if let Some(genre) = category.series_genre.as_deref() { + return Some(format!("series:{}", normalize_home_key(genre))); + } + if let Some(genre) = category.addon_genre.as_deref() { + return Some(format!("addon:{}", normalize_home_key(genre))); + } + let key = normalize_home_key(category_semantic_name(category)); + CORE_SHELF_KEYS + .iter() + .find(|candidate| key == **candidate || key.contains(*candidate)) + .map(|value| (*value).to_string()) +} + +fn cluster_overlap_ratio(first: &NativeHomeCategory, second: &NativeHomeCategory) -> f32 { + let Some(first_cluster) = cluster_key(first) else { + return 0.0; + }; + let Some(second_cluster) = cluster_key(second) else { + return 0.0; + }; + if first_cluster == second_cluster { + overlap_ratio(first, second) + } else { + 0.0 + } +} + +pub(crate) fn home_personalization_score_json( + category_json: &str, + preferred_genres_json: &str, + preferred_types_json: &str, + priority_labels_json: &str, +) -> Option { + let category = serde_json::from_str::(category_json).ok()?; + let preferred_genres = + serde_json::from_str::>(preferred_genres_json).ok()?; + let preferred_types = + serde_json::from_str::>(preferred_types_json).ok()?; + let labels = serde_json::from_str::(priority_labels_json).ok()?; + Some(personalization_score( + &category, + &preferred_genres, + &preferred_types, + &labels, + )) +} + +pub(crate) fn home_prioritize_rows_json( + categories_json: &str, + preferred_order_labels_json: &str, + preferred_genres_json: &str, + preferred_types_json: &str, + priority_labels_json: &str, +) -> Option { + let mut categories = serde_json::from_str::>(categories_json).ok()?; + let preferred_order_labels = + serde_json::from_str::>(preferred_order_labels_json).ok()?; + let preferred_genres = + serde_json::from_str::>(preferred_genres_json).ok()?; + let preferred_types = + serde_json::from_str::>(preferred_types_json).ok()?; + let labels = serde_json::from_str::(priority_labels_json).ok()?; + let preferred_order = preferred_order_labels + .iter() + .map(|value| normalize_home_key(value)) + .collect::>(); + categories.sort_by(|left, right| { + let left_index = preferred_order + .iter() + .position(|key| key == &normalize_home_key(category_semantic_name(left))) + .unwrap_or(usize::MAX); + let right_index = preferred_order + .iter() + .position(|key| key == &normalize_home_key(category_semantic_name(right))) + .unwrap_or(usize::MAX); + left_index.cmp(&right_index).then_with(|| { + personalization_score(right, &preferred_genres, &preferred_types, &labels).cmp( + &personalization_score(left, &preferred_genres, &preferred_types, &labels), + ) + }) + }); + serde_json::to_string(&categories).ok() +} + +pub(crate) fn optimize_home_rows_json(request_json: &str) -> Option { + let request = serde_json::from_str::(request_json).ok()?; + if request.categories.is_empty() { + return Some("[]".to_string()); + } + let pinned = distinct_categories( + request + .categories + .iter() + .filter(|category| is_pinned(category)) + .cloned(), + ); + let mut candidates = distinct_categories( + request + .categories + .into_iter() + .filter(|category| !is_pinned(category)), + ) + .into_iter() + .map(|mut category| { + category.items = curated_items(&category); + category + }) + .filter(|category| category.items.len() >= 4) + .collect::>(); + let preferred_order = request + .preferred_order_labels + .iter() + .map(|value| normalize_home_key(value)) + .collect::>(); + candidates.sort_by(|left, right| { + let left_index = preferred_order + .iter() + .position(|key| key == &normalize_home_key(category_semantic_name(left))) + .unwrap_or(usize::MAX); + let right_index = preferred_order + .iter() + .position(|key| key == &normalize_home_key(category_semantic_name(right))) + .unwrap_or(usize::MAX); + left_index.cmp(&right_index).then_with(|| { + personalization_score( + right, + &request.preferred_genres, + &request.preferred_types, + &request.priority_labels, + ) + .cmp(&personalization_score( + left, + &request.preferred_genres, + &request.preferred_types, + &request.priority_labels, + )) + }) + }); + + let mut kept = Vec::::new(); + for category in candidates.iter() { + let overlap = kept + .iter() + .map(|existing| overlap_ratio(existing, category)) + .fold(0.0, f32::max); + let cluster_overlap = kept + .iter() + .map(|existing| cluster_overlap_ratio(existing, category)) + .fold(0.0, f32::max); + let min_unique = category + .items + .iter() + .take(12) + .map(|item| meta_text(item, "id").to_string()) + .collect::>() + .len(); + if min_unique < 5 { + continue; + } + if is_core_genre_shelf(category) + || (overlap < 0.68 && cluster_overlap < 0.52) + || kept.len() < 8 + { + kept.push(category.clone()); + } + } + + let fallback = candidates + .into_iter() + .filter(|candidate| { + kept.iter().all(|existing| existing.id != candidate.id) + && kept.iter().all(|existing| { + overlap_ratio(existing, candidate) < 0.68 + && cluster_overlap_ratio(existing, candidate) < 0.52 + }) + }) + .take(24usize.saturating_sub(kept.len())) + .collect::>(); + let mut output = pinned; + output.extend(kept); + output.extend(fallback); + let limit = 24 + output_pinned_count(&output); + let output = distinct_categories(output.into_iter()) + .into_iter() + .take(limit) + .collect::>(); + serde_json::to_string(&output).ok() +} + +fn output_pinned_count(categories: &[NativeHomeCategory]) -> usize { + categories + .iter() + .filter(|category| is_pinned(category)) + .count() +} + +fn distinct_categories(categories: I) -> Vec +where + I: IntoIterator, +{ + let mut seen = HashSet::new(); + categories + .into_iter() + .filter(|category| seen.insert(category.id.clone())) + .collect() +} + +pub(crate) fn has_billboard_backdrop_candidate_json(meta_json: &str) -> bool { + serde_json::from_str::(meta_json) + .ok() + .is_some_and(|meta| has_backdrop_candidate(&meta)) +} + +fn has_backdrop_candidate(meta: &Value) -> bool { + let background = meta_text(meta, "background"); + !background.is_empty() && !background.eq_ignore_ascii_case(meta_text(meta, "poster")) +} + +pub(crate) fn billboard_score_candidate_json( + meta_json: &str, + days_since_release: Option, +) -> Option { + let meta = serde_json::from_str::(meta_json).ok()?; + Some(score_candidate(&meta, days_since_release)) +} + +fn score_candidate(meta: &Value, days_since_release: Option) -> i32 { + let release_boost = match days_since_release { + None => 0, + Some(days) if days < 0 => 40, + Some(days) if days <= 14 => 440, + Some(days) if days <= 45 => 280, + Some(days) if days <= 120 => 120, + Some(_) => 0, + }; + let type_boost = if meta_text(meta, "type") == "series" { + 320 + } else { + 140 + }; + let rank_boost = meta_i64(meta, "rank") + .map(|rank| (220 - ((rank as i32 - 1) * 18)).max(0)) + .unwrap_or(0); + let rating_boost = (meta_text(meta, "imdbRating").parse::().unwrap_or(0.0) * 22.0) as i32; + let recommendation_boost = if meta_text(meta, "reason").is_empty() { + 0 + } else { + 180 + }; + let editorial_boost = if meta_text(meta, "reason") == "EDITORIAL_SPOTLIGHT" { + 520 + } else { + 0 + }; + let backdrop_boost = if has_backdrop_candidate(meta) { + 260 + } else if !meta_text(meta, "poster").is_empty() { + 40 + } else { + -240 + }; + type_boost + + release_boost + + rank_boost + + rating_boost + + recommendation_boost + + editorial_boost + + backdrop_boost +} + +pub(crate) fn billboard_visual_score_json(meta_json: &str) -> Option { + let meta = serde_json::from_str::(meta_json).ok()?; + let mut score = 0; + if has_backdrop_candidate(&meta) { + score += 320; + } else { + score -= 160; + } + if !meta_text(&meta, "logo").is_empty() { + score += 120; + } + if !meta_text(&meta, "description").is_empty() { + score += 30; + } + Some(score) +} + +pub(crate) fn billboard_editorial_match_score_json( + meta_json: &str, + spec_json: &str, +) -> Option { + let meta = serde_json::from_str::(meta_json).ok()?; + let spec = serde_json::from_str::(spec_json).ok()?; + let _ = spec.title; + let release_year = meta_text(&meta, "releaseInfo").parse::().unwrap_or(0); + let year_boost = if release_year >= spec.min_year { + 400 + } else { + 0 + }; + let rating_boost = (meta_text(&meta, "imdbRating").parse::().unwrap_or(0.0) * 20.0) as i32; + let rank_boost = meta_i64(&meta, "rank") + .map(|rank| (180 - (rank as i32 * 12)).max(0)) + .unwrap_or(0); + Some(year_boost + rating_boost + rank_boost) +} + +// ── Billboard pool selection ────────────────────────────────────────────────── + +fn billboard_key_value(meta: &Value) -> String { + let id = meta_text(meta, "id"); + if let Some(iid) = imdb_id(id) { + return format!("{}:{iid}", meta_text(meta, "type")); + } + let name = meta + .get("originalName") + .and_then(Value::as_str) + .filter(|v| !v.is_empty()) + .unwrap_or_else(|| meta_text(meta, "name")); + let year = meta_text(meta, "releaseInfo") + .get(0..4) + .or_else(|| meta_text(meta, "released").get(0..4)) + .unwrap_or(""); + format!( + "{}:{}:{year}", + meta_text(meta, "type"), + normalized_billboard_title(name) + ) +} + +fn title_key_value(meta: &Value) -> String { + let name = meta + .get("originalName") + .and_then(Value::as_str) + .filter(|v| !v.is_empty()) + .unwrap_or_else(|| meta_text(meta, "name")); + normalized_billboard_title(name) +} + +fn distinct_by_billboard_key(items: Vec) -> Vec { + let mut seen = HashSet::new(); + items + .into_iter() + .filter(|m| seen.insert(billboard_key_value(m))) + .collect() +} + +fn distinct_by_title_key(items: Vec) -> Vec { + let mut seen = HashSet::new(); + items + .into_iter() + .filter(|m| seen.insert(title_key_value(m))) + .collect() +} + +fn billboard_visual_score(meta: &Value) -> i32 { + let mut score = 0i32; + if has_backdrop_candidate(meta) { + score += 320; + } else { + score -= 160; + } + if !meta_text(meta, "logo").is_empty() { + score += 120; + } + if !meta_text(meta, "description").is_empty() { + score += 30; + } + score +} + +/// Selects a billboard pool of up to 10 items from the enriched+raw candidate sets. +/// +/// `enriched_json` — candidates that have been enriched via IO (backdrop, logo, etc.). +/// `candidates_json` — the full original candidate list (before enrichment). +/// +/// Decision logic (what runs here, not on the Kotlin side): +/// • editorial picks — up to 3 items with reason == "EDITORIAL_SPOTLIGHT" +/// • series picks — up to 8 items of type "series" +/// • movie picks — up to 3 items of type "movie" +/// • combined, deduplicated, filled to 10 from remaining ranked items +pub(crate) fn build_billboard_pool_json( + enriched_json: &str, + candidates_json: &str, +) -> Option { + let enriched: Vec = serde_json::from_str(enriched_json).ok()?; + let candidates: Vec = serde_json::from_str(candidates_json).ok()?; + + let enriched_by_key: HashMap = enriched + .iter() + .map(|m| (billboard_key_value(m), m.clone())) + .collect(); + + // Editorial picks: prefer the enriched version, fall back to original when it has artwork. + let editorial_raw: Vec = candidates + .iter() + .filter(|m| meta_text(m, "reason") == "EDITORIAL_SPOTLIGHT") + .filter_map(|m| { + let key = billboard_key_value(m); + enriched_by_key.get(&key).cloned().or_else(|| { + if has_backdrop_candidate(m) || !meta_text(m, "poster").is_empty() { + Some(m.clone()) + } else { + None + } + }) + }) + .collect(); + + let mut editorial = editorial_raw; + editorial.sort_by(|a, b| score_candidate(b, None).cmp(&score_candidate(a, None))); + let editorial: Vec = distinct_by_title_key(editorial) + .into_iter() + .take(3) + .collect(); + + // Ranked pool: merge enriched + candidates, deduplicate, filter, sort by score+visual. + let combined: Vec = enriched.into_iter().chain(candidates).collect(); + let combined = distinct_by_title_key(distinct_by_billboard_key(combined)); + let mut ranked: Vec = combined + .into_iter() + .filter(|m| has_backdrop_candidate(m) || !meta_text(m, "poster").is_empty()) + .collect(); + ranked.sort_by(|a, b| { + let sb = score_candidate(b, None) + billboard_visual_score(b); + let sa = score_candidate(a, None) + billboard_visual_score(a); + sb.cmp(&sa) + }); + + let series: Vec = ranked + .iter() + .filter(|m| meta_text(m, "type") == "series") + .take(8) + .cloned() + .collect(); + let movies: Vec = ranked + .iter() + .filter(|m| meta_text(m, "type") == "movie") + .take(3) + .cloned() + .collect(); + + let preferred: Vec = distinct_by_title_key(distinct_by_billboard_key( + editorial.into_iter().chain(series).chain(movies).collect(), + )); + + let final_pool: Vec = if preferred.len() >= 10 { + preferred.into_iter().take(10).collect() + } else { + let preferred_keys: HashSet = + preferred.iter().map(billboard_key_value).collect(); + let preferred_titles: HashSet = + preferred.iter().map(title_key_value).collect(); + let extras = ranked.into_iter().filter(|m| { + !preferred_keys.contains(&billboard_key_value(m)) + && !preferred_titles.contains(&title_key_value(m)) + }); + preferred.into_iter().chain(extras).take(10).collect() + }; + + serde_json::to_string(&final_pool).ok() +} + +// ── Catalog item normalisation ──────────────────────────────────────────────── + +fn iso_date_part(date_str: &str) -> Option<&str> { + let s = date_str.trim(); + if s.len() < 10 { + return None; + } + let date_part = &s[..10]; + let b = date_part.as_bytes(); + if b[4] == b'-' && b[7] == b'-' { + Some(date_part) + } else { + None + } +} + +fn is_upcoming_date(date_str: &str, today_iso: &str) -> bool { + iso_date_part(date_str).is_some_and(|d| d > today_iso) +} + +const RANKED_CATALOG_IDS: &[&str] = &["trending", "popular", "top", "now_playing"]; + +/// Normalises catalog items on behalf of the Kotlin HomeCatalogItemNormalizer. +/// +/// Decisions kept here (Rust decides, Kotlin executes): +/// • Assign `rank = index + 1` for ranking catalogs when no genre filter is active. +/// • Drop items whose release date is still in the future (upcoming releases). +/// +/// `genre` being `None` / empty string means no genre filter is active. +/// `today_iso` must be supplied as "YYYY-MM-DD" by the caller. +pub(crate) fn normalize_home_catalog_items_json( + items_json: &str, + catalog_id: &str, + genre: Option<&str>, + today_iso: &str, +) -> Option { + let items: Vec = serde_json::from_str(items_json).ok()?; + let assign_rank = genre.map(|g| g.is_empty()).unwrap_or(true) + && RANKED_CATALOG_IDS.contains(&catalog_id); + + let mut rank: i64 = 0; + let result: Vec = items + .into_iter() + .filter_map(|mut item| { + let released = item.get("released").and_then(Value::as_str).unwrap_or(""); + if is_upcoming_date(released, today_iso) { + return None; + } + if assign_rank { + rank += 1; + if let Some(obj) = item.as_object_mut() { + obj.insert("rank".to_string(), json!(rank)); + } + } + Some(item) + }) + .collect(); + + serde_json::to_string(&result).ok() +} diff --git a/src/intro_segments.rs b/src/intro_segments.rs new file mode 100644 index 0000000..a40dc4f --- /dev/null +++ b/src/intro_segments.rs @@ -0,0 +1,173 @@ +use serde_json::{json, Value}; +use std::collections::HashMap; + +pub(crate) fn parse_intro_db_segments_json(data_json: &str) -> Option { + let data: Value = serde_json::from_str(data_json).ok()?; + let segments = collect_segments(&data); + serde_json::to_string(&segments).ok() +} + +fn collect_segments(data: &Value) -> Vec { + match data { + Value::Array(arr) => arr.iter().flat_map(collect_segments).collect(), + Value::Object(obj) => { + let mut result = Vec::new(); + for key in &["segments", "results", "data", "items"] { + if let Some(child) = obj.get(*key) { + result.extend(collect_segments(child)); + } + } + for seg_type in &["intro", "outro", "recap"] { + if let Some(child) = obj.get(*seg_type) { + if let Some(seg) = segment_from_object_with_type(child, seg_type) { + result.push(seg); + } + } + let start = number_from_keys(obj, &[ + &format!("{seg_type}Start"), &format!("{seg_type}_start"), + &format!("{seg_type}StartTime"), &format!("{seg_type}_start_time"), + &format!("{seg_type}StartMs"), &format!("{seg_type}_start_ms"), + ]); + let end = number_from_keys(obj, &[ + &format!("{seg_type}End"), &format!("{seg_type}_end"), + &format!("{seg_type}EndTime"), &format!("{seg_type}_end_time"), + &format!("{seg_type}EndMs"), &format!("{seg_type}_end_ms"), + ]); + if let (Some(s), Some(e)) = (start, end) { + let start_ms = normalize_time(s); + let end_ms = normalize_time(e); + if end_ms > start_ms { + result.push(make_segment(seg_type, start_ms, end_ms)); + } + } + } + if let Some(seg) = segment_from_object(obj) { + if !result.iter().any(|r| r == &seg) { + result.push(seg); + } + } + result.into_iter().filter(|s| { + let st = s.get("startTime").and_then(Value::as_i64).unwrap_or(0); + let et = s.get("endTime").and_then(Value::as_i64).unwrap_or(0); + et > st + }).collect() + } + _ => vec![], + } +} + +fn segment_from_object(obj: &serde_json::Map) -> Option { + let start = number_from_keys(obj, &["startTime", "start", "from", "start_sec", "start_time", "startTimeMs", "start_ms", "startOffset"])?; + let end = number_from_keys(obj, &["endTime", "end", "to", "end_sec", "end_time", "endTimeMs", "end_ms", "endOffset"])?; + let raw_type = string_from_keys(obj, &["segment_type", "skip_type", "category", "name", "type"]).unwrap_or_else(|| "intro".to_string()); + let seg_type = normalize_skip_type(&raw_type); + let start_ms = normalize_time(start); + let end_ms = normalize_time(end); + if end_ms <= start_ms { return None; } + Some(make_segment(seg_type, start_ms, end_ms)) +} + +fn segment_from_object_with_type(value: &Value, fallback_type: &str) -> Option { + let obj = value.as_object()?; + let start = number_from_keys(obj, &["startTime", "start", "from", "start_time"])?; + let end = number_from_keys(obj, &["endTime", "end", "to", "end_time"])?; + let raw_type = string_from_keys(obj, &["type", "segment_type"]).unwrap_or_else(|| fallback_type.to_string()); + let seg_type = normalize_skip_type(&raw_type); + let start_ms = normalize_time(start); + let end_ms = normalize_time(end); + if end_ms <= start_ms { return None; } + Some(make_segment(seg_type, start_ms, end_ms)) +} + +fn make_segment(seg_type: &str, start_ms: i64, end_ms: i64) -> Value { + json!({ "type": seg_type, "startTime": start_ms, "endTime": end_ms }) +} + +fn number_from_keys(obj: &serde_json::Map, keys: &[&str]) -> Option { + for key in keys { + match obj.get(*key) { + Some(Value::Number(n)) => if let Some(f) = n.as_f64() { return Some(f); }, + Some(Value::String(s)) => if let Ok(f) = s.trim().parse::() { return Some(f); }, + _ => {} + } + } + None +} + +fn string_from_keys(obj: &serde_json::Map, keys: &[&str]) -> Option { + for key in keys { + if let Some(Value::String(s)) = obj.get(*key) { + let t = s.trim(); + if !t.is_empty() { return Some(t.to_string()); } + } + } + None +} + +fn normalize_time(value: f64) -> i64 { + if value < 10_000.0 { (value * 1000.0).round() as i64 } else { value.round() as i64 } +} + +pub(crate) fn normalize_skip_type(raw: &str) -> &'static str { + match raw.to_lowercase().as_str() { + "op" | "opening" | "intro" | "mixed-intro" => "intro", + "ed" | "ending" | "outro" | "credits" => "outro", + "recap" | "previously" => "recap", + _ => "intro", + } +} + +pub(crate) fn normalize_skip_time(seconds: f64) -> i64 { + normalize_time(seconds) +} + +pub(crate) fn parse_aniskip_results_json(results_json: &str) -> Option { + let results: Value = serde_json::from_str(results_json).ok()?; + let items = results.get("results").and_then(Value::as_array)?; + let segments: Vec = items.iter().filter_map(|item| { + let skip_type = item.get("skipType").and_then(Value::as_str)?; + let interval = item.get("interval")?; + let start = interval.get("startTime").and_then(Value::as_f64)?; + let end = interval.get("endTime").and_then(Value::as_f64)?; + let start_ms = normalize_time(start); + let end_ms = normalize_time(end); + if end_ms <= start_ms { return None; } + Some(make_segment(normalize_skip_type(skip_type), start_ms, end_ms)) + }).collect(); + serde_json::to_string(&segments).ok() +} + +pub(crate) fn unique_intro_segments_json(segments_a_json: &str, segments_b_json: &str) -> Option { + let a: Vec = serde_json::from_str(segments_a_json).unwrap_or_default(); + let b: Vec = serde_json::from_str(segments_b_json).unwrap_or_default(); + dedup_and_sort(a.into_iter().chain(b.into_iter()).collect()) +} + +pub(crate) fn merge_intro_segments_json(sources_json: &str) -> Option { + let sources: Vec = serde_json::from_str(sources_json).ok()?; + let all: Vec = sources.into_iter().flat_map(|s| { + s.as_array().cloned().unwrap_or_default() + }).collect(); + dedup_and_sort(all) +} + +fn dedup_and_sort(segments: Vec) -> Option { + let mut seen: HashMap = HashMap::new(); + let mut result: Vec = Vec::new(); + for seg in segments { + let key = format!( + "{}:{}:{}", + seg.get("type").and_then(Value::as_str).unwrap_or(""), + seg.get("startTime").and_then(Value::as_i64).unwrap_or(0), + seg.get("endTime").and_then(Value::as_i64).unwrap_or(0), + ); + let end = seg.get("endTime").and_then(Value::as_i64).unwrap_or(0); + let start = seg.get("startTime").and_then(Value::as_i64).unwrap_or(0); + if end <= start { continue; } + if seen.insert(key, true).is_none() { + result.push(seg); + } + } + result.sort_by_key(|s| s.get("startTime").and_then(Value::as_i64).unwrap_or(0)); + serde_json::to_string(&result).ok() +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..ea64673 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,325 @@ +uniffi::setup_scaffolding!(); + +mod addon_protocol; +mod addon_resource; +mod addon_store; +mod app_state; +mod calendar_plan; +mod content_identity; +pub mod core_api; +pub mod core_contract; +mod data_policy; +mod discovery_plan; +mod dolby_vision_rpu; +mod external_sync; +mod headless_adapter_plan; +mod headless_engine; +mod home_ranking; +mod intro_segments; +mod library_state; +mod offline_download; +mod platform_plan; +mod player_flow; +mod player_policy; +mod player_scrobble; +mod profile_contract; +mod profile_prefs; +mod repository_flow; +mod search_plan; +mod stream_policy; +mod tmdb_plan; +mod watchlist_plan; + +pub mod addon_transport; +pub mod env; +pub mod runtime; +pub mod types; + +pub mod bindings; + +pub use core_api::FluxaCore; + +#[cfg(test)] +mod tests { + use crate::addon_protocol::{ + catalog_has_required_extra_except, catalog_requires_extra, catalog_supports_extra, + }; + use crate::content_identity::stream_request_ids; + use crate::home_ranking::optimize_home_rows_json; + use crate::stream_policy::{ + stream_playback_info_json, stream_request_headers_json, stream_request_referer, + torrent_runtime_info_json, torrent_status_info_json, + }; + use serde_json::{json, Value}; + + #[test] + fn stream_request_ids_keep_requested_tmdb_episode_before_canonical_fallback() { + assert_eq!( + stream_request_ids( + "series", + "tmdb:12345:1:2", + Some("tmdb:12345"), + Some("tmdb:12345"), + Some("tt9999999"), + ), + vec!["tmdb:12345:1:2", "tt9999999:1:2"] + ); + } + + #[test] + fn stream_request_ids_keep_custom_episode_id_before_canonical_fallback() { + assert_eq!( + stream_request_ids( + "series", + "kitsu:777:1:2", + Some("tt9999999"), + Some("kitsu:777"), + Some("tt9999999"), + ), + vec!["kitsu:777:1:2", "tt9999999:1:2"] + ); + } + + #[test] + fn stream_request_ids_prefer_canonical_for_tmdb_movies() { + assert_eq!( + stream_request_ids("movie", "tmdb:12345", None, None, Some("tt9999999")), + vec!["tt9999999", "tmdb:12345"] + ); + } + + #[test] + fn stream_playback_info_reads_stremio_stream_fields_without_rewriting_result() { + let info = stream_playback_info_json( + r#"{ + "name":"Source", + "url":"https://cdn.example/Breaking%20Bad.mkv", + "behaviorHints":{ + "videoHash":"abc123", + "videoSize":42, + "filename":"Custom File.mkv" + } + }"#, + ) + .and_then(|json| serde_json::from_str::(&json).ok()) + .expect("stream playback info"); + + assert_eq!( + info.get("playableUrl").and_then(Value::as_str), + Some("https://cdn.example/Breaking%20Bad.mkv") + ); + assert_eq!( + info.get("effectiveVideoHash").and_then(Value::as_str), + Some("abc123") + ); + assert_eq!( + info.get("effectiveVideoSize").and_then(Value::as_i64), + Some(42) + ); + assert_eq!( + info.get("effectiveFilename").and_then(Value::as_str), + Some("Custom File.mkv") + ); + assert_eq!( + info.get("subtitleExtraArgs").and_then(Value::as_str), + Some("videoHash=abc123&videoSize=42&filename=Custom+File.mkv") + ); + assert_eq!( + info.get("isLikelyPlayerCompatible") + .and_then(Value::as_bool), + Some(true) + ); + } + + #[test] + fn stream_playback_info_builds_torrent_url_from_info_hash() { + let info = stream_playback_info_json(r#"{"infoHash":"abcdef","fileIdx":3}"#) + .and_then(|json| serde_json::from_str::(&json).ok()) + .expect("stream playback info"); + + assert_eq!( + info.get("playableUrl").and_then(Value::as_str), + Some("stremio://torrent/abcdef/3") + ); + assert_eq!( + info.get("isTorrentPlaybackUrl").and_then(Value::as_bool), + Some(true) + ); + assert_eq!( + info.get("isLikelyPlayerCompatible") + .and_then(Value::as_bool), + Some(true) + ); + } + + #[test] + fn torrent_runtime_info_normalizes_link_and_resolves_file_index() { + // fileIdx provided by addon → use it directly, no episode matching + let info = torrent_runtime_info_json( + r#"{ + "link":"stremio://torrent/ABCDEF1234567890ABCDEF1234567890ABCDEF12/4", + "title":"tt123:1:2", + "requestedFileIdx":1, + "preferredFilename":null, + "sources":["tracker:udp://tracker.example:1337/announce","tracker:udp://tracker.example:1337/announce"], + "fileStats":[ + {"id":1,"path":"Show.S01E02.mkv","length":100}, + {"id":2,"path":"Show.S01E03.mkv","length":300} + ], + "rejectedIndex":null, + "baseUrl":"http://127.0.0.1:8090", + "play":true, + "stat":false + }"#, + ) + .and_then(|json| serde_json::from_str::(&json).ok()) + .expect("torrent runtime info"); + + let normalized = info + .get("normalizedLink") + .and_then(Value::as_str) + .expect("normalizedLink"); + assert!( + normalized.starts_with("magnet:?xt=urn:btih:abcdef1234567890abcdef1234567890abcdef12"), + "unexpected magnet prefix: {normalized}" + ); + // Addon-provided tracker survives dedupe (appears once). + assert_eq!( + normalized.matches("tracker.example%3A1337").count(), + 1, + "addon tracker should appear exactly once: {normalized}" + ); + // Fallback trackers are always appended for peer discovery. + assert!( + normalized.contains("opentrackr.org"), + "fallback tracker missing: {normalized}" + ); + assert_eq!(info.get("selectedFileIdx").and_then(Value::as_i64), Some(1)); + assert_eq!( + info.get("selectedReason").and_then(Value::as_str), + Some("requested") + ); + + // No fileIdx → falls back to largest video file + let fallback = torrent_runtime_info_json( + r#"{ + "link":"stremio://torrent/ABCDEF1234567890ABCDEF1234567890ABCDEF12", + "title":"tt123:1:2", + "requestedFileIdx":null, + "preferredFilename":null, + "sources":[], + "fileStats":[ + {"id":1,"path":"Show.S01E02.mkv","length":100}, + {"id":2,"path":"Show.S01E03.mkv","length":300} + ], + "rejectedIndex":null, + "baseUrl":"http://127.0.0.1:8090", + "play":true, + "stat":false + }"#, + ) + .and_then(|json| serde_json::from_str::(&json).ok()) + .expect("torrent fallback info"); + + assert_eq!(fallback.get("selectedFileIdx").and_then(Value::as_i64), Some(2)); + assert_eq!( + fallback.get("selectedReason").and_then(Value::as_str), + Some("largest-video") + ); + } + + #[test] + fn torrent_status_info_reports_progress_and_playability() { + let info = torrent_status_info_json( + r#"{ + "stat":1, + "progress":4.0, + "loaded_size":262144, + "preload_size":524288 + }"#, + ) + .and_then(|json| serde_json::from_str::(&json).ok()) + .expect("torrent status info"); + + assert_eq!(info.get("bufferProgress").and_then(Value::as_i64), Some(50)); + assert_eq!( + info.get("isPlayableEnough").and_then(Value::as_bool), + Some(false) + ); + assert_eq!( + info.get("statusKey").and_then(Value::as_str), + Some("player.torrent_status.preloading") + ); + } + + #[test] + fn stream_request_headers_keep_only_explicit_clean_headers() { + let headers = stream_request_headers_json(r#"{"X-Test":"ok","":"ignored","Blank":""}"#) + .and_then(|json| serde_json::from_str::(&json).ok()) + .expect("headers"); + + assert_eq!(headers.get("X-Test").and_then(Value::as_str), Some("ok")); + assert!(headers.get("").is_none()); + assert!(headers.get("Blank").is_none()); + assert_eq!(stream_request_referer("https://vidmoly.me/video.mp4"), None); + } + + #[test] + fn catalog_extra_helpers_match_manifest_extra_shapes() { + let modern = + r#"{"type":"movie","id":"modern","extra":[{"name":"search","isRequired":true}]}"#; + let legacy = r#"{"type":"movie","id":"legacy","extraSupported":["genre"]}"#; + + assert!(catalog_supports_extra(modern, "search")); + assert!(catalog_requires_extra(modern, "search")); + assert!(catalog_supports_extra(legacy, "genre")); + assert!(!catalog_requires_extra(legacy, "genre")); + assert!(!catalog_has_required_extra_except(modern, r#"["search"]"#)); + assert!(catalog_has_required_extra_except(modern, "[]")); + } + + #[test] + fn home_rows_keep_pinned_before_native_ranked_rows() { + let request = json!({ + "categories": [ + {"id":"popular","name":"Popular","semanticName":"Popular","type":"movie","items":[ + {"id":"p1","name":"P1","type":"movie","poster":null}, + {"id":"p2","name":"P2","type":"movie","poster":null}, + {"id":"p3","name":"P3","type":"movie","poster":null}, + {"id":"p4","name":"P4","type":"movie","poster":null}, + {"id":"p5","name":"P5","type":"movie","poster":null}, + {"id":"p6","name":"P6","type":"movie","poster":null} + ]}, + {"id":"continue_watching","name":"Continue Watching","semanticName":"Continue Watching","type":"movie","items":[ + {"id":"cw1","name":"CW1","type":"movie","poster":null} + ]}, + {"id":"trending","name":"Trending Now","semanticName":"Trending Now","type":"movie","items":[ + {"id":"t1","name":"T1","type":"movie","poster":null}, + {"id":"t2","name":"T2","type":"movie","poster":null}, + {"id":"t3","name":"T3","type":"movie","poster":null}, + {"id":"t4","name":"T4","type":"movie","poster":null}, + {"id":"t5","name":"T5","type":"movie","poster":null}, + {"id":"t6","name":"T6","type":"movie","poster":null} + ]} + ], + "preferredOrderLabels": ["Trending Now", "Popular"], + "preferredGenres": {}, + "preferredTypes": {}, + "priorityLabels": { + "trendingNow": "Trending Now", + "popularForYou": "Popular For You", + "mostWatched": "Most Watched" + } + }); + let rows = optimize_home_rows_json(&request.to_string()) + .and_then(|json| serde_json::from_str::(&json).ok()) + .expect("rows"); + let rows = rows.as_array().expect("row array"); + + assert_eq!( + rows[0].get("id").and_then(Value::as_str), + Some("continue_watching") + ); + assert_eq!(rows[1].get("id").and_then(Value::as_str), Some("trending")); + } +} diff --git a/src/library_state.rs b/src/library_state.rs new file mode 100644 index 0000000..4245b27 --- /dev/null +++ b/src/library_state.rs @@ -0,0 +1,550 @@ +use serde_json::{json, Value}; + +fn text<'a>(value: &'a Value, key: &str) -> Option<&'a str> { + value.get(key).and_then(Value::as_str) +} + +fn number(value: &Value, key: &str) -> Option { + value.get(key).and_then(Value::as_i64) +} + +fn library_item_from_meta(meta: &Value, state: Value, last_watched: Option<&str>) -> Value { + let mut item = json!({ + "_id": text(meta, "id").unwrap_or(""), + "name": text(meta, "name").unwrap_or(""), + "type": text(meta, "type").unwrap_or(""), + "poster": meta.get("poster").cloned().unwrap_or(Value::Null), + "background": meta.get("background").cloned().unwrap_or(Value::Null), + "logo": meta.get("logo").cloned().unwrap_or(Value::Null), + "state": state + }); + if let Some(last_watched) = last_watched { + item["lastWatched"] = Value::String(last_watched.to_string()); + } + item +} + +pub(crate) fn playback_progress_item_json( + meta_json: &str, + time_offset: i64, + duration: i64, + now_utc: &str, +) -> Option { + let meta: Value = serde_json::from_str(meta_json).ok()?; + let item = library_item_from_meta( + &meta, + json!({ + "lastWatched": now_utc, + "timeOffset": time_offset, + "duration": duration + }), + None, + ); + serde_json::to_string(&item).ok() +} + +pub(crate) fn clear_playback_progress_item_json(meta_json: &str) -> Option { + let meta: Value = serde_json::from_str(meta_json).ok()?; + let item = library_item_from_meta( + &meta, + json!({ + "lastWatched": Value::Null, + "timeOffset": 0, + "duration": 0, + "videoId": Value::Null, + "timesWatched": 0, + "flaggedWatched": 0 + }), + None, + ); + serde_json::to_string(&item).ok() +} + +pub(crate) fn watched_state_items_json( + meta_json: &str, + episodes_json: &str, + watched: bool, + watched_at: Option<&str>, +) -> Option { + let meta: Value = serde_json::from_str(meta_json).ok()?; + let episodes: Vec = serde_json::from_str(episodes_json).unwrap_or_default(); + let watched_value = if watched { 1 } else { 0 }; + let watched_at_value = watched_at + .map(|value| Value::String(value.to_string())) + .unwrap_or(Value::Null); + let items = if text(&meta, "type") == Some("series") && !episodes.is_empty() { + episodes + .iter() + .map(|episode| { + json!({ + "_id": text(episode, "id").unwrap_or(""), + "name": text(episode, "name").or_else(|| text(&meta, "name")).unwrap_or(""), + "type": "series", + "poster": episode.get("thumbnail").cloned().unwrap_or(Value::Null), + "background": meta.get("background").cloned().unwrap_or(Value::Null), + "logo": meta.get("logo").cloned().unwrap_or(Value::Null), + "state": { + "lastWatched": watched_at_value, + "timeOffset": 0, + "duration": 0, + "videoId": text(episode, "id").unwrap_or(""), + "timesWatched": watched_value, + "flaggedWatched": watched_value + }, + "lastWatched": watched_at_value + }) + }) + .collect::>() + } else { + vec![library_item_from_meta( + &meta, + json!({ + "lastWatched": watched_at_value, + "timeOffset": 0, + "duration": 0, + "videoId": Value::Null, + "timesWatched": watched_value, + "flaggedWatched": watched_value + }), + watched_at, + )] + }; + serde_json::to_string(&items).ok() +} + +pub(crate) fn library_continue_watching_items_json(items_json: &str) -> Option { + let mut items: Vec = serde_json::from_str(items_json).ok()?; + items.retain(|item| { + let state = item.get("state").unwrap_or(&Value::Null); + !state.is_null() + && number(state, "timeOffset").unwrap_or(0) > 0 + && number(state, "flaggedWatched").unwrap_or(0) == 0 + }); + items.sort_by(|a, b| { + let a = a + .get("state") + .and_then(|state| text(state, "lastWatched")) + .unwrap_or(""); + let b = b + .get("state") + .and_then(|state| text(state, "lastWatched")) + .unwrap_or(""); + b.cmp(a) + }); + let metas = items + .into_iter() + .map(|item| { + let state = item.get("state").unwrap_or(&Value::Null); + json!({ + "id": text(&item, "_id").unwrap_or(""), + "name": text(&item, "name").unwrap_or(""), + "type": text(&item, "type").unwrap_or(""), + "poster": item.get("poster").cloned().unwrap_or(Value::Null), + "background": item.get("background").cloned().unwrap_or(Value::Null), + "logo": item.get("logo").cloned().unwrap_or(Value::Null), + "description": Value::Null, + "timeOffset": number(state, "timeOffset"), + "duration": number(state, "duration"), + "lastVideoId": text(state, "videoId") + }) + }) + .collect::>(); + serde_json::to_string(&metas).ok() +} + +pub(crate) fn filter_home_continue_watching_json( + items_json: &str, + trakt_watched_json: &str, +) -> Option { + let items: Vec = serde_json::from_str(items_json).ok()?; + let trakt: Value = serde_json::from_str(trakt_watched_json).unwrap_or(Value::Null); + + let movie_keys: std::collections::HashSet<&str> = trakt + .get("movieKeys") + .and_then(Value::as_array) + .map(|arr| arr.iter().filter_map(Value::as_str).collect()) + .unwrap_or_default(); + let episode_keys: std::collections::HashSet<&str> = trakt + .get("episodeKeys") + .and_then(Value::as_array) + .map(|arr| arr.iter().filter_map(Value::as_str).collect()) + .unwrap_or_default(); + + let filtered: Vec<&Value> = items + .iter() + .filter(|item| { + let item_type = item.get("type").and_then(Value::as_str).unwrap_or(""); + let last_video_id = item.get("lastVideoId").and_then(Value::as_str).unwrap_or(""); + let time_offset = item.get("timeOffset").and_then(Value::as_i64).unwrap_or(0); + let duration = item.get("duration").and_then(Value::as_i64).unwrap_or(0); + let is_series = matches!(item_type, "series" | "tv" | "anime"); + let is_up_next = + is_series && !last_video_id.is_empty() && time_offset <= 0 && duration <= 0; + let has_progress = time_offset > 0 && duration > 0; + if !is_up_next && !has_progress { + return false; + } + let watched_keys = crate::content_identity::content_watched_keys_value(item); + if item_type == "movie" && !movie_keys.is_empty() { + if watched_keys.iter().any(|k| movie_keys.contains(k.as_str())) { + return false; + } + } + if is_series && !episode_keys.is_empty() && !last_video_id.is_empty() { + if let Some((_, season, episode)) = + crate::content_identity::parse_episode_locator(last_video_id) + { + if watched_keys.iter().any(|k| { + let candidate = format!("{k}:{season}:{episode}"); + episode_keys.contains(candidate.as_str()) + }) { + return false; + } + } + } + true + }) + .collect(); + + serde_json::to_string(&filtered).ok() +} + +pub(crate) fn watched_video_ids_json(items_json: &str, imdb_id: &str) -> Option { + let items: Vec = serde_json::from_str(items_json).ok()?; + let ids = items + .iter() + .filter(|item| { + text(item, "_id").is_some_and(|id| id.starts_with(imdb_id)) + && item + .get("state") + .and_then(|state| number(state, "flaggedWatched")) + == Some(1) + }) + .filter_map(|item| text(item, "_id").map(str::to_string)) + .collect::>(); + serde_json::to_string(&ids).ok() +} + +pub(crate) fn normalize_library_document_json(json: &str) -> String { + let mut lib: serde_json::Map = serde_json::from_str(json).unwrap_or_default(); + lib.insert("schemaVersion".to_string(), json!(2)); + if !lib.get("watchlist").map(Value::is_array).unwrap_or(false) { + lib.insert("watchlist".to_string(), json!([])); + } + if !lib.get("history").map(Value::is_array).unwrap_or(false) { + lib.insert("history".to_string(), json!([])); + } + if !lib.get("continueWatching").map(Value::is_array).unwrap_or(false) { + lib.insert("continueWatching".to_string(), json!([])); + } + if !lib.get("progress").map(|v| v.is_object() && !v.is_array()).unwrap_or(false) { + lib.insert("progress".to_string(), json!({})); + } + if !lib.get("watched").map(|v| v.is_object() && !v.is_array()).unwrap_or(false) { + lib.insert("watched".to_string(), json!({})); + } + serde_json::to_string(&Value::Object(lib)).unwrap_or_else(|_| "{}".to_string()) +} + +pub(crate) fn is_up_next_continue_watching_item_json(item_json: &str) -> bool { + let item: Value = serde_json::from_str(item_json).unwrap_or(Value::Null); + is_up_next_item(&item) +} + +fn is_up_next_item(item: &Value) -> bool { + let offset = item.get("timeOffset").and_then(Value::as_f64).unwrap_or(0.0); + let duration = item.get("duration").and_then(Value::as_f64).unwrap_or(0.0); + if duration <= 0.0 { return offset <= 1.0; } + let progress = offset / duration; + progress < 0.005 || progress >= 0.995 +} + +pub(crate) fn build_continue_watching_from_progress_json(progress_json: &str) -> Option { + let progress: serde_json::Map = serde_json::from_str(progress_json).ok()?; + let mut items: Vec = progress.values() + .filter_map(|entry| { + let offset = entry.get("timeOffset").and_then(Value::as_f64).unwrap_or(0.0); + let duration = entry.get("duration").and_then(Value::as_f64).unwrap_or(0.0); + let has_video_id = entry.get("lastVideoId").and_then(Value::as_str).filter(|s| !s.is_empty()).is_some(); + // Include: items with real progress OR up-next entries (offset=0 but has lastVideoId) + let include = (offset > 0.0 && duration > 0.0 && offset / duration < 0.95) + || (offset == 0.0 && has_video_id); + if !include { return None; } + let meta = entry.get("meta")?; + let id = meta.get("id").and_then(Value::as_str).unwrap_or(""); + if id.is_empty() { return None; } + Some(json!({ + "id": id, + "name": meta.get("name").and_then(Value::as_str).unwrap_or(""), + "type": meta.get("type").and_then(Value::as_str).unwrap_or(""), + "poster": meta.get("poster").cloned().unwrap_or(Value::Null), + "background": meta.get("background").cloned().unwrap_or(Value::Null), + "logo": meta.get("logo").cloned().unwrap_or(Value::Null), + "timeOffset": offset as i64, + "duration": duration as i64, + "lastVideoId": entry.get("lastVideoId").cloned().unwrap_or(Value::Null), + "lastEpisodeName": entry.get("lastEpisodeName").cloned().unwrap_or(Value::Null), + "lastEpisodeSeason": entry.get("lastEpisodeSeason").cloned().unwrap_or(Value::Null), + "lastEpisodeNumber": entry.get("lastEpisodeNumber").cloned().unwrap_or(Value::Null), + "lastEpisodeThumbnail": entry.get("lastEpisodeThumbnail").cloned().unwrap_or(Value::Null), + "lastStreamUrl": entry.get("lastStreamUrl").cloned().unwrap_or(Value::Null), + "lastStreamTitle": entry.get("lastStreamTitle").cloned().unwrap_or(Value::Null), + "lastStream": entry.get("lastStream").cloned().unwrap_or(Value::Null), + "savedAt": entry.get("savedAt").cloned().unwrap_or(Value::Null), + })) + }) + .collect(); + items.sort_by(|a, b| { + let a = a.get("savedAt").and_then(Value::as_str).unwrap_or(""); + let b = b.get("savedAt").and_then(Value::as_str).unwrap_or(""); + b.cmp(a) + }); + serde_json::to_string(&items).ok() +} + +pub(crate) fn compute_continue_watching_badges_json( + candidates_json: &str, + videos_by_series_json: &str, + last_watched_json: &str, + now_ms: i64, +) -> Option { + let mut by_id: std::collections::HashMap = { + let items: Vec = serde_json::from_str(candidates_json).unwrap_or_default(); + items.into_iter().filter_map(|item| { + let id = item.get("id").or_else(|| item.get("_id")) + .and_then(Value::as_str).map(str::to_string)?; + Some((id, item)) + }).collect() + }; + let videos_by_series: serde_json::Map = + serde_json::from_str(videos_by_series_json).unwrap_or_default(); + let last_watched: serde_json::Map = + serde_json::from_str(last_watched_json).unwrap_or_default(); + + // Track which IDs came from the real CW lists vs only from lastWatchedEpisodes. + // Candidates added only from lastWatchedEpisodes are removed when no video data + // is available to confirm a next episode exists, preventing phantom CW entries. + let cw_list_ids: std::collections::HashSet = by_id.keys().cloned().collect(); + + for (series_id, raw) in &last_watched { + let meta = match raw.get("meta") { Some(m) if m.get("type").and_then(Value::as_str) == Some("series") => m, _ => continue }; + let record = raw; + by_id.entry(series_id.clone()).or_insert_with(|| json!({ + "id": series_id, + "_id": series_id, + "type": "series", + "name": meta.get("name").cloned().unwrap_or(Value::Null), + "poster": meta.get("poster").cloned().unwrap_or(Value::Null), + "background": meta.get("background").cloned().unwrap_or(Value::Null), + "logo": meta.get("logo").cloned().unwrap_or(Value::Null), + "lastVideoId": record.get("lastVideoId").cloned().unwrap_or(Value::Null), + "lastEpisodeName": record.get("lastEpisodeName").cloned().unwrap_or(Value::Null), + "lastEpisodeSeason": record.get("lastEpisodeSeason").cloned().unwrap_or(Value::Null), + "lastEpisodeNumber": record.get("lastEpisodeNumber").cloned().unwrap_or(Value::Null), + "lastEpisodeThumbnail": record.get("lastEpisodeThumbnail").cloned().unwrap_or(Value::Null), + "timeOffset": 1, + "duration": 99999, + "savedAt": record.get("watchedAt").cloned().unwrap_or(Value::Null), + })); + } + + let mut finished_series: Vec = Vec::new(); + for (series_id, candidate) in by_id.iter_mut() { + if candidate.get("type").and_then(Value::as_str) != Some("series") { continue; } + if !is_up_next_item(candidate) { continue; } + let season = match candidate.get("lastEpisodeSeason").and_then(Value::as_i64) { Some(s) => s, None => continue }; + let episode = match candidate.get("lastEpisodeNumber").and_then(Value::as_i64) { Some(e) => e, None => continue }; + let videos = match videos_by_series.get(series_id).and_then(Value::as_array) { + Some(v) => v, + None => { + // No video data available. If this entry exists only because of + // lastWatchedEpisodes (not from any real CW list), conservatively + // remove it — we cannot confirm a next episode exists. It will + // reappear on the next home load once the addon responds. + if !cw_list_ids.contains(series_id) { + finished_series.push(series_id.clone()); + } + continue; + } + }; + let stored_badge = candidate.get("continueWatchingBadge").and_then(Value::as_str); + let stored_video_id = candidate.get("lastVideoId").and_then(Value::as_str).unwrap_or("").to_string(); + + // When the stored badge is scheduledEpisode, lastEpisodeNumber already points to the + // scheduled episode itself. Re-check that same episode rather than advancing past it. + let next = if stored_badge == Some("scheduledEpisode") { + videos.iter().find(|v| { + let vid = v.get("id").or_else(|| v.get("_id")).and_then(Value::as_str).unwrap_or(""); + vid == stored_video_id + }).cloned().or_else(|| first_episode_after(videos, season, episode)) + } else { + first_episode_after(videos, season, episode) + }; + // No next episode and we have real video data — the series is fully watched. + // Remove it from Continue Watching instead of leaving a zombie entry. + let next = match next { + Some(v) => v, + None => { finished_series.push(series_id.clone()); continue; } + }; + + let existing_video_id = stored_video_id; + let next_id = next.get("id").or_else(|| next.get("_id")).and_then(Value::as_str) + .unwrap_or(&existing_video_id).to_string(); + if !is_up_next_item(candidate) && existing_video_id != next_id { continue; } + let is_new_target = existing_video_id != next_id; + let is_released = is_episode_released(&next, now_ms); + let existing_badge = if !is_new_target { candidate.get("continueWatchingBadge").and_then(Value::as_str).map(str::to_string) } else { None }; + + let badge = if !is_released { + "scheduledEpisode" + } else if existing_badge.as_deref() == Some("scheduledEpisode") { + "newEpisode" + } else if existing_badge.is_some() { + existing_badge.as_deref().unwrap() + } else { + let watched_at = candidate.get("savedAt").and_then(Value::as_str) + .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok()) + .map(|dt| dt.timestamp_millis()).unwrap_or(now_ms); + let next_released_at = next.get("released").and_then(Value::as_str) + .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok()) + .map(|dt| dt.timestamp_millis()).unwrap_or(0); + let was_released_when_watched = next.get("released").is_none() || next_released_at <= watched_at; + if was_released_when_watched { "upNext" } else { "newEpisode" } + }.to_string(); + + let released_str = next.get("released").and_then(Value::as_str) + .map(str::to_string) + .unwrap_or_else(|| chrono::Utc::now().to_rfc3339()); + let saved_at_new = if is_new_target && badge == "newEpisode" { + Value::String(chrono::Utc::now().to_rfc3339()) + } else { + candidate.get("savedAt").cloned().unwrap_or(Value::Null) + }; + + *candidate = json!({ + "id": series_id, + "_id": series_id, + "type": "series", + "name": candidate.get("name").cloned().unwrap_or(Value::Null), + "poster": candidate.get("poster").cloned().unwrap_or(Value::Null), + "background": candidate.get("background").cloned().unwrap_or(Value::Null), + "logo": candidate.get("logo").cloned().unwrap_or(Value::Null), + "timeOffset": 1, + "duration": 99999, + "lastVideoId": next_id, + "lastEpisodeName": next.get("name").or_else(|| next.get("title")).cloned().unwrap_or(Value::Null), + "lastEpisodeSeason": next.get("season").cloned().unwrap_or(Value::Null), + "lastEpisodeNumber": next.get("episode").or_else(|| next.get("number")).cloned().unwrap_or(Value::Null), + "lastEpisodeThumbnail": next.get("thumbnail").cloned().unwrap_or(Value::Null), + "continueWatchingBadge": badge, + "newEpisodeReleasedAt": released_str, + "savedAt": saved_at_new, + }); + } + + for id in &finished_series { by_id.remove(id); } + let mut result: Vec = by_id.into_values().collect(); + result.sort_by(|a, b| { + let a_new = a.get("continueWatchingBadge").and_then(Value::as_str) == Some("newEpisode"); + let b_new = b.get("continueWatchingBadge").and_then(Value::as_str) == Some("newEpisode"); + if a_new != b_new { return if a_new { std::cmp::Ordering::Less } else { std::cmp::Ordering::Greater }; } + let a_time = a.get("savedAt").or_else(|| a.get("newEpisodeReleasedAt")).and_then(Value::as_str).unwrap_or(""); + let b_time = b.get("savedAt").or_else(|| b.get("newEpisodeReleasedAt")).and_then(Value::as_str).unwrap_or(""); + b_time.cmp(a_time) + }); + serde_json::to_string(&result).ok() +} + +fn first_episode_after(videos: &[Value], season: i64, episode: i64) -> Option { + let mut candidates: Vec<&Value> = videos.iter().filter(|v| { + let vs = v.get("season").and_then(Value::as_i64).unwrap_or(0); + let ve = v.get("episode").or_else(|| v.get("number")).and_then(Value::as_i64).unwrap_or(0); + vs > season || (vs == season && ve > episode) + }).collect(); + candidates.sort_by(|a, b| { + let as_ = a.get("season").and_then(Value::as_i64).unwrap_or(0); + let bs = b.get("season").and_then(Value::as_i64).unwrap_or(0); + if as_ != bs { return as_.cmp(&bs); } + let ae = a.get("episode").or_else(|| a.get("number")).and_then(Value::as_i64).unwrap_or(0); + let be = b.get("episode").or_else(|| b.get("number")).and_then(Value::as_i64).unwrap_or(0); + ae.cmp(&be) + }); + candidates.first().map(|v| (*v).clone()) +} + +fn is_episode_released(video: &Value, now_ms: i64) -> bool { + let released = match video.get("released").and_then(Value::as_str) { + Some(s) => s, + None => return true, + }; + match chrono::DateTime::parse_from_rfc3339(released) { + Ok(dt) => dt.timestamp_millis() <= now_ms, + Err(_) => true, + } +} + +/// Given a library JSON and a set of just-watched video IDs, update `lastWatchedEpisodes`. +/// Returns the updated library as JSON. +pub(crate) fn remember_last_watched_episodes_json(lib_json: &str, watched_ids_json: &str) -> String { + let mut lib: Value = serde_json::from_str(lib_json).unwrap_or(json!({})); + let watched_ids: std::collections::HashSet = serde_json::from_str(watched_ids_json) + .ok() + .and_then(|v: Value| v.as_array().map(|arr| { + arr.iter().filter_map(|s| s.as_str().map(str::to_string)).collect() + })) + .unwrap_or_default(); + let progress = lib.get("progress").and_then(Value::as_object).cloned().unwrap_or_default(); + let mut last_watched = lib.get("lastWatchedEpisodes").and_then(Value::as_object).cloned().unwrap_or_default(); + for (series_id, raw) in &progress { + let video_id = raw.get("lastVideoId").and_then(Value::as_str).unwrap_or(""); + if video_id.is_empty() || !watched_ids.contains(video_id) { continue; } + let meta = match raw.get("meta") { Some(m) if m.get("type").and_then(Value::as_str) == Some("series") => m, _ => continue }; + last_watched.insert(series_id.clone(), json!({ + "meta": meta, + "lastVideoId": video_id, + "lastEpisodeName": raw.get("lastEpisodeName").cloned().unwrap_or(Value::Null), + "lastEpisodeSeason": raw.get("lastEpisodeSeason").cloned().unwrap_or(Value::Null), + "lastEpisodeNumber": raw.get("lastEpisodeNumber").cloned().unwrap_or(Value::Null), + "lastEpisodeThumbnail": raw.get("lastEpisodeThumbnail").cloned().unwrap_or(Value::Null), + "watchedAt": chrono::Utc::now().to_rfc3339(), + })); + } + if let Some(obj) = lib.as_object_mut() { + obj.insert("lastWatchedEpisodes".to_string(), Value::Object(last_watched)); + } + serde_json::to_string(&lib).unwrap_or_else(|_| lib_json.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::Value; + + #[test] + fn watched_state_items_build_series_episode_payloads() { + let items = watched_state_items_json( + r#"{"id":"tt1","name":"Show","type":"series","poster":null,"background":"bg","logo":"logo"}"#, + r#"[{"id":"tt1:1:2","name":null,"season":1,"number":2,"released":null,"thumbnail":"ep.jpg"}]"#, + true, + Some("2026-01-01T00:00:00.000Z"), + ) + .and_then(|json| serde_json::from_str::(&json).ok()) + .expect("items"); + + assert_eq!( + items + .get(0) + .and_then(|item| item.get("_id")) + .and_then(Value::as_str), + Some("tt1:1:2") + ); + assert_eq!( + items + .get(0) + .and_then(|item| item.get("state")) + .and_then(|state| state.get("flaggedWatched")) + .and_then(Value::as_i64), + Some(1) + ); + } +} diff --git a/src/offline_download.rs b/src/offline_download.rs new file mode 100644 index 0000000..3cfffd8 --- /dev/null +++ b/src/offline_download.rs @@ -0,0 +1,259 @@ +use crate::stream_policy::{ + is_torrent_playback_url, stream_effective_filename, stream_playable_url, stream_text, +}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct OfflineDownloadPlanRequest { + meta: Value, + #[serde(default)] + video: Option, + #[serde(default)] + video_id: Option, + stream: Value, + #[serde(default)] + subtitle_url: Option, + download_id: String, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct OfflineDownloadPlan { + supported: bool, + #[serde(skip_serializing_if = "Option::is_none")] + reason: Option<&'static str>, + playback_url: String, + base_name: String, + video_file_name: String, + #[serde(skip_serializing_if = "Option::is_none")] + subtitle_file_name: Option, + poster_file_name: String, + background_file_name: String, + logo_file_name: String, + video_id: Option, + stream_title: Option, +} + +pub fn offline_download_plan_json(request_json: &str) -> Option { + let request: OfflineDownloadPlanRequest = serde_json::from_str(request_json).ok()?; + serde_json::to_string(&offline_download_plan(request)).ok() +} + +fn offline_download_plan(request: OfflineDownloadPlanRequest) -> OfflineDownloadPlan { + let playback_url = stream_playable_url(&request.stream).unwrap_or_default(); + let effective_filename = stream_effective_filename(&request.stream, Some(&playback_url)); + let support_error = downloadable_error(&playback_url, &request.stream); + let base_name = offline_base_name(&request.meta, request.video.as_ref(), &request.download_id); + let extension = file_extension(&playback_url, effective_filename.as_deref()); + let stream_title = raw_stream_display_title(&request.stream, &playback_url); + let subtitle_extension = request + .subtitle_url + .as_deref() + .map(subtitle_extension) + .unwrap_or_else(|| "srt".to_string()); + + OfflineDownloadPlan { + supported: support_error.is_none(), + reason: support_error, + playback_url, + video_file_name: format!("{base_name}-{}.{extension}", request.download_id), + subtitle_file_name: request + .subtitle_url + .as_ref() + .map(|_| format!("{base_name}-{}.{}", request.download_id, subtitle_extension)), + poster_file_name: format!("{base_name}-{}-poster.jpg", request.download_id), + background_file_name: format!("{base_name}-{}-background.jpg", request.download_id), + logo_file_name: format!("{base_name}-{}-logo.png", request.download_id), + video_id: request + .video + .as_ref() + .and_then(|video| text(video, "id").map(ToOwned::to_owned)) + .or(request.video_id), + stream_title, + base_name, + } +} + +fn downloadable_error(url: &str, stream: &Value) -> Option<&'static str> { + if !(url.starts_with("http://") || url.starts_with("https://")) { + return Some("unsupported_source"); + } + if is_torrent_playback_url(url) { + return Some("unsupported_source"); + } + let normalized_path = url.split('?').next().unwrap_or(url).to_lowercase(); + if [".srt", ".vtt", ".ass", ".ssa", ".ttml", ".sub"] + .iter() + .any(|ext| normalized_path.ends_with(ext)) + { + return Some("unsupported_source"); + } + if [".m3u8", ".mpd"] + .iter() + .any(|ext| normalized_path.ends_with(ext)) + { + return Some("unsupported_source"); + } + let source_text = ["name", "title", "description", "addonName"] + .iter() + .filter_map(|key| stream_text(stream, key)) + .collect::>() + .join(" ") + .to_lowercase(); + if source_text.contains("opensubtitles") + || source_text.contains("subtitle") + || source_text.contains("altyazi") + || source_text.contains("altyazı") + { + return Some("unsupported_source"); + } + None +} + +fn offline_base_name(meta: &Value, video: Option<&Value>, fallback: &str) -> String { + let mut parts = vec![]; + if let Some(name) = text(meta, "name") { + parts.push(name.to_string()); + } + if let Some(season) = video.and_then(|v| number(v, "season")) { + parts.push(format!("S{season}")); + } + if let Some(episode) = video.and_then(|v| number(v, "number")) { + parts.push(format!("E{episode}")); + } + let sanitized = sanitize_file_name(&parts.join(" ")); + if sanitized.is_empty() { + fallback.to_string() + } else { + sanitized + } +} + +fn sanitize_file_name(value: &str) -> String { + let mut output = String::with_capacity(value.len()); + let mut previous_space = false; + for character in value.chars() { + let allowed = + character.is_ascii_alphanumeric() || matches!(character, '_' | '-' | '.' | ' '); + let next = if allowed { character } else { ' ' }; + if next.is_whitespace() { + if !previous_space { + output.push(' '); + } + previous_space = true; + } else { + output.push(next); + previous_space = false; + } + } + output.trim().chars().take(80).collect() +} + +fn file_extension(url: &str, filename: Option<&str>) -> String { + if let Some(ext) = filename + .and_then(extension_part) + .filter(|ext| !ext.is_empty() && ext.len() <= 5) + { + return ext; + } + extension_part(url) + .filter(|ext| (2..=5).contains(&ext.len())) + .unwrap_or_else(|| "mp4".to_string()) +} + +fn subtitle_extension(url: &str) -> String { + let ext = extension_part(url).unwrap_or_default(); + if matches!(ext.as_str(), "srt" | "vtt" | "ass" | "ssa" | "ttml") { + ext + } else { + "srt".to_string() + } +} + +fn extension_part(value: &str) -> Option { + let path = value.split('?').next().unwrap_or(value); + path.rsplit('.').next().and_then(|ext| { + if ext == path { + None + } else { + Some(ext.to_lowercase()) + } + }) +} + +fn raw_stream_display_title(stream: &Value, playable_url: &str) -> Option { + stream_text(stream, "name") + .or_else(|| stream_text(stream, "title")) + .or_else(|| stream_text(stream, "description")) + .or_else(|| stream_text(stream, "addonName")) + .map(ToOwned::to_owned) + .or_else(|| { + if playable_url.is_empty() { + None + } else { + Some(playable_url.to_string()) + } + }) +} + +fn text<'a>(value: &'a Value, key: &str) -> Option<&'a str> { + value + .get(key) + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) +} + +fn number(value: &Value, key: &str) -> Option { + value.get(key).and_then(Value::as_i64) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn plans_downloadable_http_streams() { + let request = json!({ + "downloadId": "id1", + "meta": {"id": "tt1", "name": "Movie: Name", "type": "movie"}, + "video": {"id": "tt1:1:2", "season": 1, "number": 2}, + "stream": {"url": "https://cdn.example/video.mkv", "title": "1080p"}, + "subtitleUrl": "https://sub.example/file.vtt" + }); + + let value: Value = + serde_json::from_str(&offline_download_plan_json(&request.to_string()).unwrap()) + .unwrap(); + + assert_eq!(value["supported"], true); + assert_eq!(value["playbackUrl"], "https://cdn.example/video.mkv"); + assert_eq!(value["videoFileName"], "Movie Name S1 E2-id1.mkv"); + assert_eq!(value["subtitleFileName"], "Movie Name S1 E2-id1.vtt"); + assert_eq!(value["videoId"], "tt1:1:2"); + assert_eq!(value["streamTitle"], "1080p"); + } + + #[test] + fn rejects_torrents_manifests_and_subtitle_sources() { + for stream in [ + json!({"url": "magnet:?xt=urn:btih:abc"}), + json!({"url": "https://cdn.example/list.m3u8"}), + json!({"url": "https://cdn.example/file.srt"}), + json!({"url": "https://cdn.example/video.mp4", "addonName": "OpenSubtitles"}), + ] { + let request = json!({ + "downloadId": "id1", + "meta": {"name": "Movie"}, + "stream": stream + }); + let value: Value = + serde_json::from_str(&offline_download_plan_json(&request.to_string()).unwrap()) + .unwrap(); + assert_eq!(value["supported"], false); + assert_eq!(value["reason"], "unsupported_source"); + } + } +} diff --git a/src/platform_plan.rs b/src/platform_plan.rs new file mode 100644 index 0000000..b48ee69 --- /dev/null +++ b/src/platform_plan.rs @@ -0,0 +1,654 @@ +use crate::addon_protocol::{build_resource_url, supports_resource}; +use crate::content_identity::parse_extra_args_json; +use crate::repository_flow::addon_streams_with_provider_json; +use crate::stream_policy::stream_playback_info_json; +use serde::Deserialize; +use serde_json::{json, Map, Value}; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ResourceFetchPlanRequest { + #[serde(default)] + kind: String, + #[serde(default)] + addons: Vec, + #[serde(default)] + transport_url: Option, + #[serde(default)] + resource: Option, + #[serde(default)] + content_type: Option, + #[serde(default)] + catalog_id: Option, + #[serde(default)] + id: Option, + #[serde(default)] + request_ids: Vec, + #[serde(default)] + extra: Map, + #[serde(default)] + extra_raw: String, + #[serde(default)] + query: Option, + #[serde(default)] + genre: Option, + #[serde(default)] + skip: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ResourceParseRequest { + #[serde(default)] + kind: String, + #[serde(default)] + response: Value, + #[serde(default)] + addon_name: Option, + #[serde(default)] + season: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PlaybackPrepareRequest { + stream: Value, + #[serde(default)] + meta: Option, + #[serde(default)] + episode: Option, + #[serde(default)] + preferred_player: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct LibraryLocalStateRequest { + #[serde(default)] + library: Value, + #[serde(default)] + primary_id: Option, + #[serde(default)] + fallback_id: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PreferenceUpdateRequest { + #[serde(default)] + existing: Map, + key: String, + value: Value, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct AddonCollectionMutationRequest { + #[serde(default)] + existing: Vec, + #[serde(default)] + incoming: Vec, + #[serde(default)] + remove_key: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct DetailEpisodePlanRequest { + #[serde(default)] + episodes: Vec, + #[serde(default)] + selected_season: Option, + #[serde(default)] + selected_episode_id: Option, + #[serde(default)] + meta_id: Option, +} + +pub(crate) fn resource_fetch_plan_json(request_json: &str) -> Option { + let request = serde_json::from_str::(request_json).ok()?; + let mut requests = Vec::::new(); + + match request.kind.as_str() { + "catalogPage" => { + let transport_url = request.transport_url.as_deref()?; + let content_type = request.content_type.as_deref()?; + let catalog_id = request.catalog_id.as_deref()?; + requests.push(json!({ + "url": build_resource_url(transport_url, "catalog", content_type, catalog_id, extra_json(&request).as_deref()), + "kind": "catalogPage" + })); + } + "search" => { + let query = request.query.as_deref().unwrap_or(""); + for addon in &request.addons { + let Some(transport_url) = addon_transport_url(addon) else { continue }; + for catalog in addon_catalogs(addon) { + if !catalog_supports_search(&catalog) { + continue; + } + let Some(content_type) = catalog.get("type").and_then(Value::as_str) else { continue }; + let Some(id) = catalog.get("id").and_then(Value::as_str) else { continue }; + requests.push(json!({ + "url": build_resource_url(transport_url, "catalog", content_type, id, Some(&json!({"search": query}).to_string())), + "kind": "search", + "addonName": addon_display_name(addon), + "catalogId": id, + "catalogType": content_type, + "categoryId": format!("{}:{}:{}", transport_url, content_type, id), + "categoryName": search_category_name(addon, &catalog, content_type) + })); + } + } + } + "discover" => { + let genre = request.genre.as_deref(); + for catalog in discover_catalog_options(&request.addons, request.content_type.as_deref().unwrap_or("")) { + let extra = genre.map(|value| json!({"genre": value}).to_string()); + requests.push(json!({ + "url": build_resource_url( + &catalog.transport_url, + "catalog", + &catalog.content_type, + &catalog.id, + extra.as_deref() + ), + "kind": "discover", + "catalogKey": catalog.key + })); + } + } + "metaDetail" => { + let content_type = request.content_type.as_deref()?; + let id = request.id.as_deref()?; + for addon in &request.addons { + if !addon_supports(addon, "meta", content_type, Some(id)) { + continue; + } + let Some(transport_url) = addon_transport_url(addon) else { continue }; + requests.push(json!({ + "url": build_resource_url(transport_url, "meta", content_type, id, None), + "kind": "metaDetail", + "addonName": addon_display_name(addon), + "stopOnFirstResult": true + })); + } + } + "streams" => { + let content_type = request.content_type.as_deref()?; + for addon in &request.addons { + if !addon_supports(addon, "stream", content_type, None) { + continue; + } + let Some(transport_url) = addon_transport_url(addon) else { continue }; + for id in &request.request_ids { + requests.push(json!({ + "url": build_resource_url(transport_url, "stream", content_type, id, None), + "kind": "streams", + "addonName": addon_display_name(addon) + })); + } + } + } + "seasonEpisodes" => { + let series_id = request.id.as_deref()?; + for addon in &request.addons { + if !addon_supports(addon, "meta", "series", Some(series_id)) { + continue; + } + let Some(transport_url) = addon_transport_url(addon) else { continue }; + requests.push(json!({ + "url": build_resource_url(transport_url, "meta", "series", series_id, None), + "kind": "seasonEpisodes", + "addonName": addon_display_name(addon), + "stopOnFirstResult": true + })); + } + } + "subtitles" => { + let content_type = request.content_type.as_deref()?; + let id = request.id.as_deref()?; + for addon in &request.addons { + if !addon_supports(addon, "subtitles", content_type, Some(id)) { + continue; + } + let Some(transport_url) = addon_transport_url(addon) else { continue }; + requests.push(json!({ + "url": build_resource_url(transport_url, "subtitles", content_type, id, None), + "kind": "subtitles", + "addonName": addon_display_name(addon) + })); + if !request.extra_raw.trim().is_empty() { + requests.push(json!({ + "url": build_resource_url( + transport_url, + "subtitles", + content_type, + id, + parse_extra_args_json(&request.extra_raw).as_deref() + ), + "kind": "subtitles", + "addonName": addon_display_name(addon) + })); + } + } + } + _ => { + let transport_url = request.transport_url.as_deref()?; + let resource = request.resource.as_deref()?; + let content_type = request.content_type.as_deref()?; + let id = request.id.as_deref()?; + requests.push(json!({ + "url": build_resource_url(transport_url, resource, content_type, id, extra_json(&request).as_deref()), + "kind": request.kind + })); + } + } + + serde_json::to_string(&json!({ "requests": requests })).ok() +} + +pub(crate) fn resource_parse_plan_json(request_json: &str) -> Option { + let request = serde_json::from_str::(request_json).ok()?; + let response = request.response; + let value = match request.kind.as_str() { + "catalogPage" | "discover" | "search" => { + json!({ "items": response.get("metas").and_then(Value::as_array).cloned().unwrap_or_default() }) + } + "metaDetail" => json!({ "meta": response.get("meta").cloned().unwrap_or(Value::Null) }), + "streams" => { + let streams = response + .get("streams") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + let normalized = addon_streams_with_provider_json( + &Value::Array(streams).to_string(), + request.addon_name.as_deref().unwrap_or(""), + ); + json!({ "streams": serde_json::from_str::(&normalized).unwrap_or(Value::Array(vec![])) }) + } + "seasonEpisodes" => { + let videos = response + .get("meta") + .and_then(|meta| meta.get("videos")) + .and_then(Value::as_array) + .cloned() + .unwrap_or_default() + .into_iter() + .filter(|video| { + request.season.is_none() + || video.get("season").and_then(Value::as_i64) == request.season + }) + .collect::>(); + json!({ "episodes": videos }) + } + "subtitles" => { + json!({ "subtitles": response.get("subtitles").and_then(Value::as_array).cloned().unwrap_or_default() }) + } + _ => response, + }; + serde_json::to_string(&value).ok() +} + +pub(crate) fn playback_prepare_plan_json(request_json: &str) -> Option { + let request = serde_json::from_str::(request_json).ok()?; + let info = stream_playback_info_json(&request.stream.to_string()) + .and_then(|raw| serde_json::from_str::(&raw).ok()) + .unwrap_or(Value::Null); + let playable_url = info + .get("playableUrl") + .or_else(|| request.stream.get("playableUrl")) + .or_else(|| request.stream.get("url")) + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + let is_torrent = info + .get("isTorrentPlaybackUrl") + .and_then(Value::as_bool) + .unwrap_or(false) + || playable_url.starts_with("stremio://torrent/") + || request.stream.get("infoHash").and_then(Value::as_str).is_some(); + let compatible = info + .get("isLikelyPlayerCompatible") + .and_then(Value::as_bool) + .unwrap_or(true); + let mode = if playable_url.is_empty() { + "reject" + } else if !compatible { + "reject" + } else if is_torrent { + "torrent" + } else { + "direct" + }; + serde_json::to_string(&json!({ + "mode": mode, + "url": playable_url, + "isTorrent": is_torrent, + "rejectReason": if playable_url.is_empty() { "missing_playable_url" } else if !compatible { "incompatible_stream" } else { "" }, + "subtitleExtraArgs": info.get("subtitleExtraArgs").cloned().unwrap_or(Value::Null), + "title": playback_title(request.meta.as_ref(), request.episode.as_ref(), &request.stream), + "artwork": playback_artwork(request.meta.as_ref(), request.episode.as_ref()), + "preferredPlayer": request.preferred_player.unwrap_or_else(|| "mpv".to_string()) + })) + .ok() +} + +pub(crate) fn library_local_state_plan_json(request_json: &str) -> Option { + let request = serde_json::from_str::(request_json).ok()?; + let id = request + .primary_id + .as_deref() + .or(request.fallback_id.as_deref()) + .unwrap_or(""); + let progress = request + .library + .get("progress") + .and_then(|value| value.get(id)) + .cloned() + .unwrap_or(Value::Null); + let is_in_watchlist = request + .library + .get("watchlist") + .and_then(Value::as_array) + .is_some_and(|items| { + items + .iter() + .any(|item| item.get("id").and_then(Value::as_str) == Some(id)) + }); + let watched_video_ids = request + .library + .get("watched") + .and_then(Value::as_object) + .map(|watched| { + watched + .iter() + .filter(|(key, value)| key.starts_with(id) && value.as_bool().unwrap_or(false)) + .map(|(key, _)| Value::String(key.clone())) + .collect::>() + }) + .unwrap_or_default(); + serde_json::to_string(&json!({ + "progress": progress, + "isInWatchlist": is_in_watchlist, + "watchedVideoIds": watched_video_ids + })) + .ok() +} + +pub(crate) fn preferences_schema_json() -> String { + json!({ + "keys": [ + "language", + "startPage", + "preferredPlayer", + "streamSourceSelectionMode", + "streamSourceRegexPattern", + "preferredAudioLanguage", + "secondaryAudioLanguage", + "preferredSubtitleLanguage", + "secondarySubtitleLanguage", + "subtitleSize", + "playbackSpeed", + "torrentSpeedPreset", + "torrentCachePreset", + "downloadSourceSelectionMode", + "downloadSubtitleLanguage" + ] + }) + .to_string() +} + +pub(crate) fn apply_preference_update_json(request_json: &str) -> Option { + let request = serde_json::from_str::(request_json).ok()?; + let mut updated = request.existing; + let value = normalize_preference_value(&request.key, request.value); + updated.insert(request.key, value); + serde_json::to_string(&Value::Object(updated)).ok() +} + +pub(crate) fn addon_collection_mutation_plan_json(request_json: &str) -> Option { + let request = serde_json::from_str::(request_json).ok()?; + let mut addons = request.existing; + if let Some(remove_key) = request.remove_key.as_deref() { + addons.retain(|addon| addon_key(addon) != remove_key); + } + for incoming in request.incoming { + let key = addon_key(&incoming); + if key.is_empty() { + continue; + } + if let Some(existing) = addons.iter_mut().find(|addon| addon_key(addon) == key) { + *existing = incoming; + } else { + addons.push(incoming); + } + } + serde_json::to_string(&json!({ "addons": addons })).ok() +} + +pub(crate) fn detail_episode_plan_json(request_json: &str) -> Option { + let request = serde_json::from_str::(request_json).ok()?; + let mut seasons = request + .episodes + .iter() + .filter_map(|episode| episode.get("season").and_then(Value::as_i64).or(Some(1))) + .collect::>(); + seasons.sort_unstable(); + seasons.dedup(); + // Search for the target episode across ALL episodes before season filtering, + // so that a lastVideoId from a later season (e.g. S9 when default would be S1) is found. + let target_episode = request + .selected_episode_id + .as_deref() + .and_then(|id| { + request + .episodes + .iter() + .find(|ep| ep.get("id").and_then(Value::as_str) == Some(id)) + .cloned() + }); + let selected_season = target_episode + .as_ref() + .and_then(|ep| ep.get("season").and_then(Value::as_i64)) + .or_else(|| request.selected_season.filter(|season| seasons.contains(season))) + .or_else(|| seasons.first().copied()) + .unwrap_or(1); + let episodes = request + .episodes + .into_iter() + .filter(|episode| episode.get("season").and_then(Value::as_i64).unwrap_or(1) == selected_season) + .collect::>(); + let selected_episode = target_episode + .filter(|ep| ep.get("season").and_then(Value::as_i64).unwrap_or(1) == selected_season) + .or_else(|| episodes.first().cloned()); + serde_json::to_string(&json!({ + "seasonNumbers": seasons, + "selectedSeason": selected_season, + "episodes": episodes, + "selectedEpisode": selected_episode, + "streamRequestId": selected_episode + .as_ref() + .and_then(|episode| episode.get("id").and_then(Value::as_str)) + .or(request.meta_id.as_deref()) + })) + .ok() +} + +fn extra_json(request: &ResourceFetchPlanRequest) -> Option { + let mut extra = request.extra.clone(); + if let Some(genre) = request.genre.as_ref().filter(|value| !value.is_empty()) { + extra.insert("genre".to_string(), Value::String(genre.clone())); + } + if let Some(search) = request.query.as_ref().filter(|value| !value.is_empty()) { + extra.insert("search".to_string(), Value::String(search.clone())); + } + if let Some(skip) = request.skip.filter(|value| *value > 0) { + extra.insert("skip".to_string(), Value::Number(skip.into())); + } + (!extra.is_empty()).then(|| Value::Object(extra).to_string()) +} + +fn addon_transport_url(addon: &Value) -> Option<&str> { + addon.get("transportUrl").and_then(Value::as_str) +} + +fn addon_manifest(addon: &Value) -> Value { + addon.get("manifest").cloned().unwrap_or_else(|| addon.clone()) +} + +fn addon_catalogs(addon: &Value) -> Vec { + addon_manifest(addon) + .get("catalogs") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default() +} + +fn addon_supports(addon: &Value, resource: &str, content_type: &str, id: Option<&str>) -> bool { + let manifest = addon_manifest(addon); + supports_resource(&manifest.to_string(), resource, Some(content_type), id) +} + +fn addon_display_name(addon: &Value) -> String { + addon.get("name") + .or_else(|| addon.get("manifest").and_then(|manifest| manifest.get("name"))) + .and_then(Value::as_str) + .unwrap_or("Unknown Addon") + .to_string() +} + +fn catalog_supports_search(catalog: &Value) -> bool { + catalog + .get("extra") + .and_then(Value::as_array) + .is_some_and(|extra| { + extra + .iter() + .any(|item| item.get("name").and_then(Value::as_str) == Some("search")) + }) + || catalog + .get("extraSupported") + .and_then(Value::as_array) + .is_some_and(|extra| extra.iter().any(|item| item.as_str() == Some("search"))) +} + +fn search_category_name(addon: &Value, catalog: &Value, content_type: &str) -> String { + let addon_name = addon_display_name(addon); + let catalog_name = catalog + .get("name") + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .unwrap_or(match content_type { + "movie" => "Movies", + "series" => "Series", + other => other, + }); + format!("{addon_name} - {catalog_name}") +} + +struct DiscoverCatalog { + key: String, + transport_url: String, + content_type: String, + id: String, +} + +fn discover_catalog_options(addons: &[Value], selected_type: &str) -> Vec { + let mut options = Vec::new(); + for addon in addons { + let Some(transport_url) = addon_transport_url(addon) else { continue }; + for catalog in addon_catalogs(addon) { + let Some(content_type) = catalog.get("type").and_then(Value::as_str) else { continue }; + let Some(id) = catalog.get("id").and_then(Value::as_str) else { continue }; + if !selected_type.is_empty() && content_type != selected_type { + continue; + } + options.push(DiscoverCatalog { + key: format!("{}:{}", transport_url, id), + transport_url: transport_url.to_string(), + content_type: content_type.to_string(), + id: id.to_string(), + }); + } + } + options +} + +fn playback_title(meta: Option<&Value>, episode: Option<&Value>, stream: &Value) -> Value { + let content_title = meta + .and_then(|value| value.get("name")) + .or_else(|| stream.get("title")) + .or_else(|| stream.get("name")) + .and_then(Value::as_str) + .unwrap_or("Fluxa"); + let season = episode.and_then(|value| value.get("season")).and_then(Value::as_i64); + let episode_number = episode + .and_then(|value| value.get("episode").or_else(|| value.get("number"))) + .and_then(Value::as_i64); + let episode_name = episode + .and_then(|value| value.get("name").or_else(|| value.get("title"))) + .and_then(Value::as_str); + let episode_line = match (season, episode_number) { + (Some(season), Some(number)) => { + let prefix = format!("S{season}:E{number}"); + Some(match episode_name.filter(|value| !value.trim().is_empty()) { + Some(name) => format!("{prefix} {}", name.trim()), + None => prefix, + }) + } + _ => None, + }; + json!({ "contentTitle": content_title, "episodeLine": episode_line }) +} + +fn playback_artwork(meta: Option<&Value>, episode: Option<&Value>) -> Value { + let background = meta + .and_then(|value| first_text(value, &["background", "backgroundUrl", "backdrop", "backdropUrl"])) + .or_else(|| episode.and_then(|value| value.get("thumbnail")).and_then(Value::as_str)) + .or_else(|| meta.and_then(|value| value.get("poster")).and_then(Value::as_str)); + let logo = meta.and_then(|value| first_text(value, &["logo", "logoUrl", "titleLogo", "titleLogoUrl"])); + json!({ "background": background, "logo": logo }) +} + +fn first_text<'a>(value: &'a Value, keys: &[&str]) -> Option<&'a str> { + keys.iter() + .find_map(|key| value.get(*key).and_then(Value::as_str).filter(|text| !text.trim().is_empty())) +} + +fn normalize_preference_value(key: &str, value: Value) -> Value { + match key { + "preferredPlayer" => enum_string(value, &["mpv", "exoplayer", "external"], "mpv"), + "streamSourceSelectionMode" | "downloadSourceSelectionMode" => { + enum_string(value, &["manual", "first", "best", "regex"], "manual") + } + "downloadSubtitleLanguage" => enum_string( + value, + &["off", "preferred", "tr", "en", "ja", "es", "fr", "de"], + "preferred", + ), + "torrentSpeedPreset" => enum_string(value, &["default", "fast", "ultra_fast"], "default"), + "torrentCachePreset" => enum_string(value, &["auto", "2gb", "5gb", "10gb", "unlimited"], "auto"), + "subtitleSize" => enum_string(value, &["50", "75", "100", "125", "150", "200"], "100"), + _ => value, + } +} + +fn enum_string(value: Value, allowed: &[&str], fallback: &str) -> Value { + let text = value.as_str().unwrap_or(fallback); + if allowed.contains(&text) { + Value::String(text.to_string()) + } else { + Value::String(fallback.to_string()) + } +} + +fn addon_key(addon: &Value) -> String { + addon.get("transportUrl") + .or_else(|| addon.get("id")) + .or_else(|| addon.get("manifest").and_then(|manifest| manifest.get("id"))) + .and_then(Value::as_str) + .unwrap_or("") + .to_string() +} diff --git a/src/player_flow.rs b/src/player_flow.rs new file mode 100644 index 0000000..488b06a --- /dev/null +++ b/src/player_flow.rs @@ -0,0 +1,227 @@ +use crate::stream_policy; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct PlayerFlowState { + #[serde(default)] + current_video_id: Option, + #[serde(default)] + current_streams: Vec, + #[serde(default)] + current_stream_index: i32, + #[serde(default)] + current_url: Option, + #[serde(default)] + zero_speed_ticks: i32, + #[serde(default)] + is_buffering: bool, + #[serde(default)] + is_video_rendered: bool, + #[serde(default)] + player_error: Option, + #[serde(default)] + preferred_binge_group: Option, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde( + rename_all = "camelCase", + rename_all_fields = "camelCase", + tag = "type" +)] +enum PlayerFlowAction { + #[serde(rename = "loadStreamsRequested")] + LoadStreamsRequested { + content_type: String, + id: String, + current_video_id: Option, + initial_video_id: Option, + initial_streams: Vec, + initial_stream_index: i32, + }, + #[serde(rename = "streamsLoaded")] + StreamsLoaded { + streams: Vec, + current_video_id: Option, + initial_stream_index: i32, + saved_url: Option, + saved_title: Option, + source_selection_mode: Option, + regex_pattern: Option, + preferred_binge_group: Option, + }, + #[serde(rename = "streamsFailed")] + StreamsFailed { error_code: Option }, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct PlayerFlowResult { + state: PlayerFlowState, + effects: Vec, +} + +#[derive(Clone, Debug, Serialize)] +#[serde( + rename_all = "camelCase", + rename_all_fields = "camelCase", + tag = "type" +)] +enum PlayerFlowEffect { + #[serde(rename = "loadStreams")] + LoadStreams { + content_type: String, + id: String, + use_initial_streams: bool, + }, +} + +pub(crate) fn player_flow_dispatch_json(state_json: &str, action_json: &str) -> Option { + let mut state: PlayerFlowState = serde_json::from_str(state_json).unwrap_or_default(); + let action: PlayerFlowAction = serde_json::from_str(action_json).ok()?; + let effects = dispatch(&mut state, action); + serde_json::to_string(&PlayerFlowResult { state, effects }).ok() +} + +fn dispatch(state: &mut PlayerFlowState, action: PlayerFlowAction) -> Vec { + match action { + PlayerFlowAction::LoadStreamsRequested { + content_type, + id, + current_video_id, + initial_video_id, + initial_streams, + initial_stream_index, + } => { + state.current_video_id = current_video_id.clone(); + state.current_streams.clear(); + state.current_stream_index = initial_stream_index; + state.current_url = None; + state.zero_speed_ticks = 0; + state.is_buffering = true; + state.is_video_rendered = false; + state.player_error = None; + let use_initial_streams = + !initial_streams.is_empty() && current_video_id == initial_video_id; + vec![PlayerFlowEffect::LoadStreams { + content_type, + id, + use_initial_streams, + }] + } + PlayerFlowAction::StreamsLoaded { + streams, + current_video_id, + initial_stream_index, + saved_url, + saved_title, + source_selection_mode, + regex_pattern, + preferred_binge_group, + } => { + if streams.is_empty() { + state.current_streams.clear(); + state.current_url = None; + state.is_buffering = false; + state.player_error = Some("no_source".to_string()); + return vec![]; + } + + let selected_index = stream_policy::select_stream_index_values( + &streams, + current_video_id.as_deref().unwrap_or_default(), + initial_stream_index, + saved_url.as_deref(), + saved_title.as_deref(), + source_selection_mode.as_deref().unwrap_or("manual"), + regex_pattern.as_deref(), + preferred_binge_group.as_deref(), + ) + .clamp(0, streams.len().saturating_sub(1) as i32); + + state.current_streams = streams; + state.current_stream_index = selected_index; + state.current_url = state + .current_streams + .get(selected_index as usize) + .and_then(playable_url); + state.is_buffering = state.current_url.is_none(); + state.is_video_rendered = false; + state.player_error = None; + state.preferred_binge_group = None; + vec![] + } + PlayerFlowAction::StreamsFailed { error_code } => { + state.current_url = None; + state.is_buffering = false; + state.player_error = Some(error_code.unwrap_or_else(|| "generic".to_string())); + vec![] + } + } +} + +fn playable_url(stream: &Value) -> Option { + stream + .get("playableUrl") + .and_then(Value::as_str) + .filter(|v| !v.is_empty()) + .map(ToString::to_string) + .or_else(|| stream_policy::stream_playable_url(stream)) +} + +#[cfg(test)] +mod tests { + use super::player_flow_dispatch_json; + use serde_json::Value; + + #[test] + fn load_request_returns_effect_and_resets_playback_state() { + let result: Value = serde_json::from_str( + &player_flow_dispatch_json( + r#"{"currentUrl":"http://old","isVideoRendered":true}"#, + r#"{"type":"loadStreamsRequested","contentType":"series","id":"tt1:1:2","currentVideoId":"tt1:1:2","initialVideoId":"tt1:1:2","initialStreams":[{"playableUrl":"http://s"}],"initialStreamIndex":2}"#, + ) + .expect("result"), + ) + .expect("json"); + + assert_eq!(result["state"]["currentUrl"], Value::Null); + assert_eq!(result["state"]["isBuffering"], true); + assert_eq!(result["effects"][0]["type"], "loadStreams"); + assert_eq!(result["effects"][0]["useInitialStreams"], true); + } + + #[test] + fn loaded_streams_select_url_without_reordering_provider_results() { + let result: Value = serde_json::from_str( + &player_flow_dispatch_json( + "{}", + r#"{"type":"streamsLoaded","streams":[{"title":"A","playableUrl":"http://a"},{"title":"B","playableUrl":"http://b"}],"currentVideoId":"tt1","initialStreamIndex":1,"sourceSelectionMode":"manual"}"#, + ) + .expect("result"), + ) + .expect("json"); + + assert_eq!(result["state"]["currentStreamIndex"], 1); + assert_eq!(result["state"]["currentUrl"], "http://b"); + assert_eq!(result["state"]["currentStreams"][0]["title"], "A"); + assert_eq!(result["state"]["currentStreams"][1]["title"], "B"); + } + + #[test] + fn empty_streams_return_no_source_error_code() { + let result: Value = serde_json::from_str( + &player_flow_dispatch_json( + "{}", + r#"{"type":"streamsLoaded","streams":[],"initialStreamIndex":0}"#, + ) + .expect("result"), + ) + .expect("json"); + + assert_eq!(result["state"]["playerError"], "no_source"); + assert_eq!(result["state"]["isBuffering"], false); + } +} diff --git a/src/player_policy.rs b/src/player_policy.rs new file mode 100644 index 0000000..1bfa75b --- /dev/null +++ b/src/player_policy.rs @@ -0,0 +1,1324 @@ +use serde::Deserialize; +use serde_json::{json, Value}; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct BackendSelectionRequest { + #[serde(default)] + stream: Value, + #[serde(default)] + preferred_player: Option, + #[serde(default)] + device_has_dolby_vision_decoder: bool, + #[serde(default)] + device_has_hdr_display: bool, + #[serde(default)] + force_software_audio: bool, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct TorrentFallbackRequest { + #[serde(default)] + file_stats: Vec, + #[serde(default)] + rejected_index: Option, + #[serde(default)] + video_id: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct BufferTargetsRequest { + #[serde(default)] + forward_buffer_seconds: Option, + #[serde(default)] + back_buffer_seconds: Option, + #[serde(default)] + cache_size_mb: Option, + #[serde(default)] + is_torrent: bool, + #[serde(default)] + mobile_data_usage: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RetryPolicyRequest { + error_code: String, + #[serde(default)] + retry_count: i32, + #[serde(default)] + is_torrent: bool, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SourceSidebarRequest { + #[serde(default)] + streams: Vec, + #[serde(default)] + current_stream_index: i32, + #[serde(default)] + available_addons: Vec, + #[serde(default)] + selected_addon: Option, +} + +pub(crate) fn player_backend_selection_json(request_json: &str) -> Option { + let request = serde_json::from_str::(request_json).ok()?; + let preferred = request.preferred_player.as_deref().unwrap_or("internal"); + let stream = &request.stream; + + let url = stream + .get("playableUrl") + .or_else(|| stream.get("url")) + .and_then(Value::as_str) + .unwrap_or(""); + + let is_external_player_url = url.starts_with("intent://") + || stream + .get("externalPlayerUrl") + .and_then(Value::as_bool) + .unwrap_or(false) + || preferred == "external"; + + if is_external_player_url { + return serde_json::to_string(&json!({ + "backend": "external", + "reason": "external_player_preference" + })) + .ok(); + } + + // MPV is preferred for: + // - HDR / Dolby Vision streams when device doesn't have native HW decoder + // - Streams that specify mpv hints + // - User explicitly chose MPV + let has_mpv_hint = stream + .get("behaviorHints") + .and_then(|h| h.get("preferMpv")) + .and_then(Value::as_bool) + .unwrap_or(false); + + let is_dv_stream = stream.get("dv").and_then(Value::as_bool).unwrap_or(false) + || stream.get("dolbyVision").and_then(Value::as_bool).unwrap_or(false); + let is_hdr_stream = stream.get("hdr").and_then(Value::as_bool).unwrap_or(false); + let needs_mpv_for_hdr = (is_dv_stream && !request.device_has_dolby_vision_decoder) + || (is_hdr_stream && !request.device_has_hdr_display); + + let use_mpv = preferred == "mpv" + || has_mpv_hint + || needs_mpv_for_hdr + || (request.force_software_audio && preferred != "exoplayer"); + + let backend = if use_mpv { "mpv" } else { "exoplayer" }; + let reason = if preferred == "mpv" || preferred == "exoplayer" { + "user_preference" + } else if has_mpv_hint { + "stream_hint" + } else if needs_mpv_for_hdr { + "hdr_no_hw_decoder" + } else if request.force_software_audio { + "software_audio" + } else { + "default" + }; + + serde_json::to_string(&json!({ + "backend": backend, + "reason": reason + })) + .ok() +} + +/// Determine fallback file indexes to try when the primary torrent file fails validation. +/// Excludes the rejected index and prioritizes by file size. +pub(crate) fn torrent_fallback_file_policy_json(request_json: &str) -> Option { + let request = serde_json::from_str::(request_json).ok()?; + let rejected = request.rejected_index; + let video_id = request.video_id.as_deref().unwrap_or(""); + + // Collect video-likely files (by extension) + let video_exts = [ + ".mkv", ".mp4", ".avi", ".mov", ".wmv", ".flv", ".webm", ".m4v", + ]; + let mut candidates: Vec<(i32, i64)> = request + .file_stats + .iter() + .filter_map(|stat| { + let id = stat.get("id").and_then(Value::as_i64)? as i32; + if rejected == Some(id) { + return None; + } + let path = stat.get("path").and_then(Value::as_str).unwrap_or("").to_lowercase(); + let is_video = video_exts.iter().any(|ext| path.ends_with(ext)); + if !is_video { + return None; + } + let length = stat.get("length").and_then(Value::as_i64).unwrap_or(0); + // Skip tiny files (less than 1MB) unless it's the only candidate + if length < 1_000_000 && request.file_stats.len() > 1 { + return None; + } + Some((id, length)) + }) + .collect(); + + // Sort by size descending (largest first as most likely the right video file) + candidates.sort_by(|a, b| b.1.cmp(&a.1)); + + // If we have a video_id hint, try to match by episode pattern + let fallback_ids: Vec = if !video_id.is_empty() { + // Episode-matched first, then size-sorted remainder + let mut matched: Vec<(i32, i64)> = Vec::new(); + let mut unmatched: Vec<(i32, i64)> = Vec::new(); + for (id, length) in &candidates { + let path = request + .file_stats + .iter() + .find(|s| s.get("id").and_then(Value::as_i64) == Some(*id as i64)) + .and_then(|s| s.get("path").and_then(Value::as_str)) + .unwrap_or(""); + if episode_path_matches_id(path, video_id) { + matched.push((*id, *length)); + } else { + unmatched.push((*id, *length)); + } + } + matched + .iter() + .chain(unmatched.iter()) + .map(|(id, _)| *id) + .collect() + } else { + candidates.iter().map(|(id, _)| *id).collect() + }; + + serde_json::to_string(&json!({ + "fallbackFileIndexes": fallback_ids, + "rejectedIndex": rejected + })) + .ok() +} + +/// Return safe buffer and cache targets for ExoPlayer given preferences and stream type. +pub(crate) fn player_buffer_targets_json(request_json: &str) -> Option { + let request = serde_json::from_str::(request_json).ok()?; + let mobile_data_usage = request.mobile_data_usage.as_deref().unwrap_or("medium"); + + // On mobile data, reduce buffers + let data_factor: f64 = match mobile_data_usage { + "low" => 0.5, + "high" => 1.5, + _ => 1.0, + }; + + let base_forward_ms = request + .forward_buffer_seconds + .unwrap_or(120) + .clamp(10, 600) as f64 + * 1000.0 + * data_factor; + let base_back_ms = request + .back_buffer_seconds + .unwrap_or(30) + .clamp(5, 120) as f64 + * 1000.0; + + // Torrent streams need smaller buffers to avoid filling the local proxy + let (forward_ms, back_ms) = if request.is_torrent { + (base_forward_ms.min(30_000.0), base_back_ms.min(15_000.0)) + } else { + (base_forward_ms, base_back_ms) + }; + + let cache_bytes = request + .cache_size_mb + .map(|mb| mb.clamp(10, 2000) * 1_000_000) + .unwrap_or(100 * 1_000_000i64); + + serde_json::to_string(&json!({ + "forwardBufferMs": forward_ms as i64, + "backBufferMs": back_ms as i64, + "cacheSizeBytes": cache_bytes + })) + .ok() +} + +/// Return the retry/fallback policy given an error code and retry history. +pub(crate) fn player_retry_policy_json(request_json: &str) -> Option { + let request = serde_json::from_str::(request_json).ok()?; + let error_code = request.error_code.as_str(); + let retry_count = request.retry_count; + + // Non-retryable errors + let is_fatal = matches!( + error_code, + "no_source" + | "drm_not_supported" + | "drm_session_error" + | "format_unsupported" + | "missing_profile" + ); + + if is_fatal || retry_count >= 3 { + return serde_json::to_string(&json!({ + "shouldRetry": false, + "fallbackAction": if is_fatal { "show_error" } else { "show_error_with_retry_button" }, + "delayMs": 0 + })) + .ok(); + } + + // Torrent errors get a longer delay + let (should_retry, delay_ms, fallback_action) = if request.is_torrent { + match error_code { + "timeout" | "connection_error" | "buffer_timeout" => { + (true, 2000u64 * (retry_count as u64 + 1), "retry_stream") + } + "torrent_no_file" | "torrent_file_validation_failed" => { + (true, 1000, "try_fallback_file") + } + _ => (false, 0, "show_error"), + } + } else { + match error_code { + "timeout" | "connection_error" | "io_error" => { + (true, 1000u64 * (retry_count as u64 + 1), "retry_stream") + } + "renderer_error" | "decode_error" => (true, 500, "retry_with_sw_decoder"), + _ => (false, 0, "show_error"), + } + }; + + serde_json::to_string(&json!({ + "shouldRetry": should_retry, + "fallbackAction": fallback_action, + "delayMs": delay_ms, + "retryCount": retry_count + })) + .ok() +} + +/// Build the source sidebar option state: which streams to show and which is selected. +pub(crate) fn player_source_sidebar_plan_json(request_json: &str) -> Option { + let request = serde_json::from_str::(request_json).ok()?; + let current_index = request.current_stream_index.clamp(0, i32::MAX); + let streams_by_addon: std::collections::BTreeMap> = request + .streams + .iter() + .enumerate() + .fold(std::collections::BTreeMap::new(), |mut acc, (i, stream)| { + let addon_name = stream + .get("addonName") + .and_then(Value::as_str) + .unwrap_or("Unknown") + .to_string(); + acc.entry(addon_name).or_default().push((i, stream)); + acc + }); + + let groups: Vec = streams_by_addon + .into_iter() + .map(|(addon_name, streams)| { + let entries: Vec = streams + .iter() + .map(|(idx, stream)| { + json!({ + "index": idx, + "isSelected": *idx == current_index as usize, + "title": stream.get("title").cloned().unwrap_or_else(|| json!("")), + "name": stream.get("name").cloned().unwrap_or_else(|| json!("")), + "quality": stream.get("quality").cloned().unwrap_or(Value::Null) + }) + }) + .collect(); + json!({ + "addonName": addon_name, + "streams": entries, + "isSelected": entries.iter().any(|e| e["isSelected"].as_bool().unwrap_or(false)) + }) + }) + .collect(); + + serde_json::to_string(&json!({ + "groups": groups, + "currentStreamIndex": current_index, + "availableAddons": request.available_addons, + "selectedAddon": request.selected_addon + })) + .ok() +} + +// ── DV proxy plan ───────────────────────────────────────────────────────────── + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct DvProxyPlanRequest { + #[serde(default)] + stream: Value, + #[serde(default)] + url: String, + /// "off" | "hdr10" | "dv8" | "auto" + #[serde(default = "default_auto")] + fallback_mode: String, + #[serde(default)] + device_has_dv_decoder: bool, + #[serde(default)] + device_has_dv_display: bool, +} + +fn default_auto() -> String { + "auto".to_string() +} + +/// Per-stream Dolby Vision profile classification. +#[derive(Debug, Clone, Copy, PartialEq)] +enum DvProfile { + /// Profile 4 — AVC single-layer, no HDR base layer. + P4, + /// Profile 5 — HEVC single-layer, no HDR base layer. + P5, + /// Profile 7 — dual-layer BL+EL with HDR10-compatible base. + P7, + /// Profile 8, compat_id=1 — single-layer HEVC, HDR10 base. Safest fallback. + P8Hdr10, + /// Profile 8, compat_id=4 — single-layer HEVC, HLG base. + P8Hlg, + /// Profile 8 with unrecognised compat_id — HDR10 assumed but uncertain. + P8Unknown, + /// Profile 10, compat_id=1 — HDR10-compatible base. + P10Hdr10, + /// Profile 10, compat_id 0/2/3 — no HDR10 base. + P10Other, + /// Profile could not be determined from stream metadata. + Unknown, +} + +impl DvProfile { + fn label(self) -> &'static str { + match self { + DvProfile::P4 => "P4", + DvProfile::P5 => "P5", + DvProfile::P7 => "P7", + DvProfile::P8Hdr10 => "P8.1", + DvProfile::P8Hlg => "P8.4", + DvProfile::P8Unknown => "P8", + DvProfile::P10Hdr10 => "P10_compat1", + DvProfile::P10Other => "P10_other", + DvProfile::Unknown => "unknown", + } + } +} + +/// Returns the recommended DV proxy action for a single stream+URL combination. +/// +/// Response fields: +/// action — "none" | "dvcc_strip" | "rpu_convert" +/// rpuMode — libdovi convert mode (2 = Profile 8) +/// reason — machine-readable decision code +/// profile — detected DV profile ("P7", "P8.1", …, "unknown") +/// compatibility — expected output format ("HDR10", "HLG", "DV8", "DV", "none") +/// safety — "high" | "medium" | "low" | "none" +/// limitations — list of known caveats for this action +pub(crate) fn dv_proxy_plan_json(request_json: &str) -> Option { + let req = serde_json::from_str::(request_json).ok()?; + + if req.fallback_mode == "off" { + return plan_rich("none", "user_disabled", "unknown", "none", "high", &[]); + } + + // HLS / DASH manifests are rewritten by the OkHttp interceptor — no proxy needed. + let url_lower = req.url.to_lowercase(); + if url_lower.ends_with(".m3u8") + || url_lower.contains(".m3u8?") + || url_lower.ends_with(".mpd") + || url_lower.contains(".mpd?") + { + return plan_rich("none", "manifest_handled", "unknown", "none", "high", &[]); + } + + if !is_dolby_vision_stream(&req.stream, &req.url) { + return plan_rich("none", "not_dv", "unknown", "none", "high", &[]); + } + + // Native passthrough: decoder + display always wins regardless of mode. + // Exception for convert_dv81: decoder without display should still convert P7 → P8.1 + // so the decoder applies dynamic RPU tone mapping rather than static HDR10. + let native_passthrough = req.device_has_dv_decoder + && (req.device_has_dv_display || req.fallback_mode != "convert_dv81"); + if native_passthrough { + return plan_rich("none", "hw_dv_decoder", "unknown", "DV", "high", &[]); + } + + let profile = detect_dv_profile(&req.stream); + let container = detect_container(&req.url); + + // Hard safety gates: profiles with no HDR base layer cannot be safely + // rewritten — stripping DVCC would expose a DV-only bitstream to an + // HDR10 decoder, producing corrupted colour. + match profile { + DvProfile::P4 | DvProfile::P5 => { + return plan_rich( + "none", + "no_hdr_base_layer", + profile.label(), + "none", + "none", + &["p4_p5_no_hdr_fallback_possible"], + ); + } + DvProfile::P10Other => { + return plan_rich( + "none", + "p10_compat_id_no_hdr_base", + profile.label(), + "none", + "none", + &["only_p10_compat_id_1_has_hdr10_base"], + ); + } + // Unknown profile: do nothing rather than guess and corrupt playback. + DvProfile::Unknown => { + return plan_rich( + "none", + "unknown_profile_no_safe_fallback", + "unknown", + "none", + "none", + &["set_dvProfile_field_or_codec_string_for_safe_rewrite"], + ); + } + _ => {} + } + + let mode = req.fallback_mode.as_str(); + + let (action, compat, safety, reason, limitations): (&str, &str, &str, &str, Vec<&str>) = + match profile { + DvProfile::P7 => match (mode, &container) { + // convert_dv81 + DV decoder (no display): RPU conversion for Annex-B + fMP4. + // Without a DV decoder, conversion would produce DV8.1 that nothing can decode; + // fall through to dvcc_strip (handled by the catch-all arm below). + ("convert_dv81", _) if req.device_has_dv_decoder => ( + "rpu_convert", + "DV8", + "medium", + "p7_rpu_convert_to_dv81", + vec![], + ), + ("dv8", DvContainer::RawHevc) => ( + "rpu_convert", + "DV8", + "medium", + "p7_rpu_convert_to_dv8_annexb", + vec!["annexb_only"], + ), + // Auto mode + DV-capable display: keep DV via RPU conversion. + ("auto", DvContainer::RawHevc) if req.device_has_dv_display => ( + "rpu_convert", + "DV8", + "medium", + "p7_rpu_convert_auto_dv_display_annexb", + vec!["annexb_only"], + ), + // dv8 mode requested but container is not Annex-B. + ("dv8", _) => ( + "dvcc_strip", + "HDR10", + "medium", + "rpu_convert_rejected_not_annexb", + vec!["rpu_convert_requires_annexb_hevc", "container_is_not_raw_hevc_fallback_to_dvcc_strip", "header_only_patch", "does_not_transcode", "does_not_remove_rpu_nals"], + ), + _ => ( + "dvcc_strip", + "HDR10", + "medium", + "p7_dvcc_strip_hdr10_base", + vec!["does_not_convert_bitstream", "rpu_nals_remain_in_stream_ignored", "header_only_patch", "does_not_transcode", "does_not_remove_rpu_nals"], + ), + }, + DvProfile::P8Hdr10 => ( + "dvcc_strip", + "HDR10", + "low", + "p8_1_hdr10_compat_base", + vec!["single_layer_hdr10_base_fully_compatible", "header_only_patch", "does_not_transcode", "does_not_remove_rpu_nals"], + ), + DvProfile::P8Hlg => ( + "dvcc_strip", + "HLG", + "medium", + "p8_4_hlg_compat_base", + vec!["hlg_base_not_hdr10_color_rendering_may_differ", "header_only_patch", "does_not_transcode", "does_not_remove_rpu_nals"], + ), + DvProfile::P8Unknown => ( + "dvcc_strip", + "HDR10_assumed", + "medium", + "p8_compat_id_unknown_hdr10_assumed", + vec!["compat_id_unknown_hdr10_base_assumed", "header_only_patch", "does_not_transcode", "does_not_remove_rpu_nals"], + ), + DvProfile::P10Hdr10 => ( + "dvcc_strip", + "HDR10", + "medium", + "p10_compat_id_1_hdr10_base", + vec!["does_not_convert_bitstream", "header_only_patch", "does_not_transcode", "does_not_remove_rpu_nals"], + ), + // P4, P5, P10Other, Unknown already returned above. + _ => unreachable!(), + }; + + plan_rich(action, reason, profile.label(), compat, safety, &limitations) +} + +fn plan_rich( + action: &str, + reason: &str, + profile: &str, + compatibility: &str, + safety: &str, + limitations: &[&str], +) -> Option { + serde_json::to_string(&json!({ + "action": action, + "rpuMode": 2u8, + "reason": reason, + "profile": profile, + "compatibility": compatibility, + "safety": safety, + "limitations": limitations, + })) + .ok() +} + +/// Derive the DV profile from stream metadata, codec strings, and text hints. +fn detect_dv_profile(stream: &Value) -> DvProfile { + // 1. Explicit integer fields set by the addon. + let profile_num = stream + .get("dvProfile") + .or_else(|| stream.get("dv_profile")) + .and_then(Value::as_i64); + let compat_id = stream + .get("dvCompatId") + .or_else(|| stream.get("dvCompatibility")) + .and_then(Value::as_i64); + if let Some(p) = profile_num { + return profile_from_nums(p, compat_id); + } + + // 2. ISO-BMFF / HLS codec string: "dvhe.07.06", "dvh1.08.01", … + let codecs = stream.get("codecs").and_then(Value::as_str).unwrap_or(""); + if let Some(p) = parse_dv_codec_string(codecs) { + return p; + } + + // 3. Codec token embedded in freetext fields (e.g., "dvhe.07.06 BDRemux"). + let name = stream.get("name").and_then(Value::as_str).unwrap_or(""); + let desc = stream.get("description").and_then(Value::as_str).unwrap_or(""); + let filename = stream + .get("effectiveFilename") + .or_else(|| stream.get("filename")) + .and_then(Value::as_str) + .unwrap_or(""); + let text = format!("{} {} {}", name, desc, filename); + if let Some(p) = parse_dv_codec_string(&text) { + return p; + } + + // 4. Short profile tokens: "P8.1", "P7", "P8", … + parse_dv_profile_text(&text).unwrap_or(DvProfile::Unknown) +} + +fn profile_from_nums(profile: i64, compat_id: Option) -> DvProfile { + match profile { + 4 => DvProfile::P4, + 5 => DvProfile::P5, + 7 => DvProfile::P7, + 8 => match compat_id { + Some(1) => DvProfile::P8Hdr10, + Some(4) => DvProfile::P8Hlg, + _ => DvProfile::P8Unknown, + }, + 10 => match compat_id { + Some(1) => DvProfile::P10Hdr10, + _ => DvProfile::P10Other, + }, + _ => DvProfile::Unknown, + } +} + +/// Parse a DV fourcc codec string such as "dvhe.07.06" → P7. +fn parse_dv_codec_string(text: &str) -> Option { + let lower = text.to_lowercase(); + for prefix in &["dvhe.", "dvh1.", "dva1.", "dvav."] { + if let Some(pos) = lower.find(prefix) { + let after = &text[pos + prefix.len()..]; + let mut parts = after.splitn(3, '.'); + // Take only the leading digits from each field (e.g. "08" from "08.01 Remux"). + let profile: i64 = leading_digits(parts.next()?)?.parse().ok()?; + let compat: Option = parts.next() + .and_then(leading_digits) + .and_then(|s| s.parse().ok()); + return Some(profile_from_nums(profile, compat)); + } + } + None +} + +fn leading_digits(s: &str) -> Option<&str> { + let end = s.find(|c: char| !c.is_ascii_digit()).unwrap_or(s.len()); + if end == 0 { None } else { Some(&s[..end]) } +} + +/// Recognise short profile tokens ("P8.1", "P7", "P8") in freetext. +fn parse_dv_profile_text(text: &str) -> Option { + // Ordered so longer patterns match before their shorter prefixes. + let patterns: &[(&str, DvProfile)] = &[ + ("P8.1", DvProfile::P8Hdr10), + ("P8.4", DvProfile::P8Hlg), + ("P7", DvProfile::P7), + ("P8", DvProfile::P8Unknown), + ("P10", DvProfile::P10Other), + ("P5", DvProfile::P5), + ("P4", DvProfile::P4), + ]; + for (pat, profile) in patterns { + if contains_word(text, pat) { + return Some(*profile); + } + } + None +} + +/// True when `word` appears in `text` surrounded by non-alphanumeric (or absent) bytes. +fn contains_word(text: &str, word: &str) -> bool { + let tb = text.as_bytes(); + let wb = word.as_bytes(); + let wlen = wb.len(); + if tb.len() < wlen { + return false; + } + for i in 0..=(tb.len() - wlen) { + if &tb[i..i + wlen] == wb { + let before_ok = i == 0 || !tb[i - 1].is_ascii_alphanumeric(); + let after_ok = i + wlen >= tb.len() || !tb[i + wlen].is_ascii_alphanumeric(); + if before_ok && after_ok { + return true; + } + } + } + false +} + +/// Returns true when the stream or URL is identifiable as Dolby Vision content. +fn is_dolby_vision_stream(stream: &Value, url: &str) -> bool { + if stream.get("dv").and_then(Value::as_bool).unwrap_or(false) + || stream.get("dolbyVision").and_then(Value::as_bool).unwrap_or(false) + || stream.get("dvProfile").and_then(Value::as_i64).is_some() + { + return true; + } + + let name = stream.get("name").and_then(Value::as_str).unwrap_or(""); + let desc = stream.get("description").and_then(Value::as_str).unwrap_or(""); + let filename = stream + .get("effectiveFilename") + .or_else(|| stream.get("filename")) + .and_then(Value::as_str) + .unwrap_or(""); + + let lower = format!("{} {} {} {}", name, desc, filename, url).to_lowercase(); + + if lower.contains("dvhe") + || lower.contains("dvh1") + || lower.contains("dva1") + || lower.contains("dvav") + || lower.contains("dolby vision") + || lower.contains("dolby-vision") + || lower.contains("dovi") + { + return true; + } + + // "DV" as a standalone token (case-sensitive — avoids "DVD", "HDVD", etc.). + is_standalone_dv_token(&format!("{} {} {} {}", name, desc, filename, url)) +} + +fn is_standalone_dv_token(text: &str) -> bool { + let bytes = text.as_bytes(); + let len = bytes.len(); + let mut i = 0; + while i + 1 < len { + if bytes[i] == b'D' && bytes[i + 1] == b'V' { + let before_ok = i == 0 || !bytes[i - 1].is_ascii_alphabetic(); + let after_ok = i + 2 >= len || !bytes[i + 2].is_ascii_alphanumeric(); + if before_ok && after_ok { + return true; + } + } + i += 1; + } + false +} + +enum DvContainer { + Mkv, + Mp4, + RawHevc, + Unknown, +} + +fn detect_container(url: &str) -> DvContainer { + let path = url.split('?').next().unwrap_or(url).to_lowercase(); + let ext = path.rsplit('.').next().unwrap_or(""); + match ext { + "mkv" | "mk3d" | "mka" | "mks" | "webm" => DvContainer::Mkv, + "mp4" | "m4v" | "m4a" | "mov" => DvContainer::Mp4, + "hevc" | "h265" | "265" => DvContainer::RawHevc, + _ => DvContainer::Unknown, + } +} + +fn episode_path_matches_id(path: &str, video_id: &str) -> bool { + let parts: Vec<&str> = video_id.split(':').collect(); + if parts.len() < 3 { + return false; + } + let season = parts[1].parse::().unwrap_or(0); + let episode = parts[2].parse::().unwrap_or(0); + if season == 0 || episode == 0 { + return false; + } + let path_lower = path.to_lowercase(); + let pattern_s_e = format!("s{:02}e{:02}", season, episode); + let pattern_sx_ex = format!("{}x{:02}", season, episode); + let pattern_ep = format!("e{:02}", episode); + path_lower.contains(&pattern_s_e) + || path_lower.contains(&pattern_sx_ex) + || path_lower.contains(&pattern_ep) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::Value; + + #[test] + fn backend_selection_defaults_to_exoplayer() { + let result: Value = serde_json::from_str( + &player_backend_selection_json( + r#"{"stream":{"url":"http://example.com/video.mp4"}}"#, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(result["backend"], "exoplayer"); + } + + #[test] + fn backend_selection_respects_mpv_user_preference() { + let result: Value = serde_json::from_str( + &player_backend_selection_json( + r#"{"stream":{"url":"http://example.com/video.mp4"},"preferredPlayer":"mpv"}"#, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(result["backend"], "mpv"); + assert_eq!(result["reason"], "user_preference"); + } + + #[test] + fn torrent_fallback_excludes_rejected_index_and_sorts_by_size() { + let result: Value = serde_json::from_str( + &torrent_fallback_file_policy_json( + r#"{"rejectedIndex":1,"fileStats":[{"id":1,"path":"Big.mkv","length":1000000000},{"id":2,"path":"Small.mkv","length":500000000},{"id":3,"path":"Extras.mkv","length":200000000}]}"#, + ) + .unwrap(), + ) + .unwrap(); + let fallback: Vec = result["fallbackFileIndexes"] + .as_array() + .unwrap() + .iter() + .map(|v| v.as_i64().unwrap()) + .collect(); + assert!(!fallback.contains(&1), "rejected index must be excluded"); + assert_eq!(fallback[0], 2, "largest remaining file should be first"); + } + + #[test] + fn buffer_targets_reduces_forward_buffer_for_torrent() { + let torrent_result: Value = serde_json::from_str( + &player_buffer_targets_json( + r#"{"forwardBufferSeconds":120,"backBufferSeconds":30,"isTorrent":true}"#, + ) + .unwrap(), + ) + .unwrap(); + let direct_result: Value = serde_json::from_str( + &player_buffer_targets_json( + r#"{"forwardBufferSeconds":120,"backBufferSeconds":30,"isTorrent":false}"#, + ) + .unwrap(), + ) + .unwrap(); + assert!(torrent_result["forwardBufferMs"].as_i64().unwrap() < direct_result["forwardBufferMs"].as_i64().unwrap()); + } + + #[test] + fn retry_policy_is_not_retryable_for_no_source() { + let result: Value = serde_json::from_str( + &player_retry_policy_json(r#"{"errorCode":"no_source","retryCount":0}"#).unwrap(), + ) + .unwrap(); + assert_eq!(result["shouldRetry"], false); + } + + #[test] + fn retry_policy_retries_connection_errors_with_backoff() { + let result: Value = serde_json::from_str( + &player_retry_policy_json( + r#"{"errorCode":"timeout","retryCount":1,"isTorrent":false}"#, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(result["shouldRetry"], true); + assert!(result["delayMs"].as_i64().unwrap() > 0); + } + + // ── dv_proxy_plan_json ───────────────────────────────────────────────────── + + fn plan(json: &str) -> Value { + serde_json::from_str(&dv_proxy_plan_json(json).unwrap()).unwrap() + } + + // ── Routing / mode gates ─────────────────────────────────────────────────── + + #[test] + fn dv_proxy_off_mode_returns_none() { + let p = plan(r#"{"stream":{"name":"4K DV HDR","dvProfile":7},"url":"https://cdn.example/movie.mkv","fallbackMode":"off"}"#); + assert_eq!(p["action"], "none"); + assert_eq!(p["reason"], "user_disabled"); + } + + #[test] + fn dv_proxy_hls_url_defers_to_manifest_rewrite() { + let p = plan(r#"{"stream":{"name":"4K DV","dvProfile":7},"url":"https://cdn.example/index.m3u8","fallbackMode":"auto"}"#); + assert_eq!(p["action"], "none"); + assert_eq!(p["reason"], "manifest_handled"); + } + + #[test] + fn dv_proxy_dash_url_defers_to_manifest_rewrite() { + let p = plan(r#"{"stream":{"name":"4K DV","dvProfile":7},"url":"https://cdn.example/stream.mpd","fallbackMode":"auto"}"#); + assert_eq!(p["action"], "none"); + assert_eq!(p["reason"], "manifest_handled"); + } + + #[test] + fn dv_proxy_non_dv_stream_returns_none() { + let p = plan(r#"{"stream":{"name":"1080p HDR AVC"},"url":"https://cdn.example/movie.mkv","fallbackMode":"auto"}"#); + assert_eq!(p["action"], "none"); + assert_eq!(p["reason"], "not_dv"); + } + + #[test] + fn dv_proxy_hw_dv_decoder_skips_proxy() { + let p = plan(r#"{"stream":{"name":"4K DV","dvProfile":7},"url":"https://cdn.example/movie.mkv","fallbackMode":"auto","deviceHasDvDecoder":true}"#); + assert_eq!(p["action"], "none"); + assert_eq!(p["reason"], "hw_dv_decoder"); + } + + // ── Safety gates: profiles with no HDR base layer ───────────────────────── + + #[test] + fn dv_proxy_p5_no_dv_decoder_returns_none() { + let p = plan(r#"{"stream":{"dvProfile":5},"url":"https://cdn.example/movie.mkv","fallbackMode":"auto","deviceHasDvDecoder":false}"#); + assert_eq!(p["action"], "none"); + assert_eq!(p["reason"], "no_hdr_base_layer"); + assert_eq!(p["profile"], "P5"); + } + + #[test] + fn dv_proxy_p4_no_dv_decoder_returns_none() { + let p = plan(r#"{"stream":{"dvProfile":4},"url":"https://cdn.example/movie.mkv","fallbackMode":"auto","deviceHasDvDecoder":false}"#); + assert_eq!(p["action"], "none"); + assert_eq!(p["reason"], "no_hdr_base_layer"); + assert_eq!(p["profile"], "P4"); + } + + #[test] + fn dv_proxy_p10_compat_0_returns_none() { + let p = plan(r#"{"stream":{"dvProfile":10,"dvCompatId":0},"url":"https://cdn.example/movie.mkv","fallbackMode":"auto","deviceHasDvDecoder":false}"#); + assert_eq!(p["action"], "none"); + assert_eq!(p["reason"], "p10_compat_id_no_hdr_base"); + } + + #[test] + fn dv_proxy_p10_compat_2_returns_none() { + let p = plan(r#"{"stream":{"dvProfile":10,"dvCompatId":2},"url":"https://cdn.example/movie.mkv","fallbackMode":"auto","deviceHasDvDecoder":false}"#); + assert_eq!(p["action"], "none"); + } + + #[test] + fn dv_proxy_unknown_profile_returns_none() { + // DV detected but no profile info → safe default is none. + let p = plan(r#"{"stream":{"name":"Dolby Vision"},"url":"https://cdn.example/movie.mkv","fallbackMode":"auto","deviceHasDvDecoder":false}"#); + assert_eq!(p["action"], "none"); + assert_eq!(p["reason"], "unknown_profile_no_safe_fallback"); + } + + // ── Profile-specific actions and safety levels ───────────────────────────── + + #[test] + fn dv_proxy_p7_mkv_auto_gives_dvcc_strip_medium_safety() { + let p = plan(r#"{"stream":{"dvProfile":7},"url":"https://cdn.example/movie.mkv","fallbackMode":"auto","deviceHasDvDecoder":false,"deviceHasDvDisplay":false}"#); + assert_eq!(p["action"], "dvcc_strip"); + assert_eq!(p["profile"], "P7"); + assert_eq!(p["compatibility"], "HDR10"); + assert_eq!(p["safety"], "medium"); + } + + #[test] + fn dv_proxy_p8_1_gives_dvcc_strip_low_safety() { + let p = plan(r#"{"stream":{"dvProfile":8,"dvCompatId":1},"url":"https://cdn.example/movie.mkv","fallbackMode":"auto","deviceHasDvDecoder":false}"#); + assert_eq!(p["action"], "dvcc_strip"); + assert_eq!(p["profile"], "P8.1"); + assert_eq!(p["compatibility"], "HDR10"); + assert_eq!(p["safety"], "low"); + } + + #[test] + fn dv_proxy_p8_4_fallback_is_hlg_not_hdr10() { + let p = plan(r#"{"stream":{"dvProfile":8,"dvCompatId":4},"url":"https://cdn.example/movie.mkv","fallbackMode":"auto","deviceHasDvDecoder":false}"#); + assert_eq!(p["action"], "dvcc_strip"); + assert_eq!(p["profile"], "P8.4"); + assert_eq!(p["compatibility"], "HLG"); + assert_ne!(p["compatibility"], "HDR10"); + } + + #[test] + fn dv_proxy_p8_unknown_compat_strips_with_assumed_hdr10() { + // "DV P8" in name → P8Unknown → strip, medium safety, HDR10_assumed + let p = plan(r#"{"stream":{"name":"DV P8"},"url":"https://debrid.example/file.mkv","fallbackMode":"hdr10","deviceHasDvDecoder":false}"#); + assert_eq!(p["action"], "dvcc_strip"); + assert_eq!(p["profile"], "P8"); + assert_eq!(p["compatibility"], "HDR10_assumed"); + assert_eq!(p["safety"], "medium"); + } + + #[test] + fn dv_proxy_p10_compat_1_gives_dvcc_strip() { + let p = plan(r#"{"stream":{"dvProfile":10,"dvCompatId":1},"url":"https://cdn.example/movie.mkv","fallbackMode":"auto","deviceHasDvDecoder":false}"#); + assert_eq!(p["action"], "dvcc_strip"); + assert_eq!(p["profile"], "P10_compat1"); + assert_eq!(p["compatibility"], "HDR10"); + } + + // ── rpu_convert: only for raw Annex-B HEVC ──────────────────────────────── + + #[test] + fn dv_proxy_p7_raw_hevc_dv8_mode_gives_rpu_convert() { + let p = plan(r#"{"stream":{"dvProfile":7},"url":"https://cdn.example/stream.hevc","fallbackMode":"dv8","deviceHasDvDecoder":false}"#); + assert_eq!(p["action"], "rpu_convert"); + assert_eq!(p["rpuMode"], 2); + assert_eq!(p["profile"], "P7"); + } + + #[test] + fn dv_proxy_p7_raw_hevc_auto_dv_display_gives_rpu_convert() { + let p = plan(r#"{"stream":{"dvProfile":7},"url":"https://cdn.example/stream.hevc","fallbackMode":"auto","deviceHasDvDecoder":false,"deviceHasDvDisplay":true}"#); + assert_eq!(p["action"], "rpu_convert"); + } + + #[test] + fn dv_proxy_rpu_convert_rejected_for_mkv_falls_back_to_dvcc_strip() { + // dv8 mode + MKV without a DV decoder → falls back to dvcc_strip because + // rpu_convert needs a DV decoder in the convert_dv81 path, and dv8 mode + // is annexb-only (rejects non-raw-HEVC containers). + let p = plan(r#"{"stream":{"dvProfile":7},"url":"https://cdn.example/movie.mkv","fallbackMode":"dv8","deviceHasDvDecoder":false}"#); + assert_eq!(p["action"], "dvcc_strip"); + assert_eq!(p["reason"], "rpu_convert_rejected_not_annexb"); + } + + #[test] + fn dv_proxy_rpu_convert_rejected_for_mp4_falls_back_to_dvcc_strip() { + let p = plan(r#"{"stream":{"dvProfile":7},"url":"https://cdn.example/movie.mp4","fallbackMode":"dv8","deviceHasDvDecoder":false}"#); + assert_eq!(p["action"], "dvcc_strip"); + assert_eq!(p["reason"], "rpu_convert_rejected_not_annexb"); + } + + // ── DV text detection ────────────────────────────────────────────────────── + + #[test] + fn dv_detection_dolby_vision_p8_text_gives_action() { + // "P8" token → P8Unknown → dvcc_strip + let p = plan(r#"{"stream":{"name":"Dolby Vision P8"},"url":"https://cdn.example/f.mkv","fallbackMode":"auto"}"#); + assert_ne!(p["action"], "none"); + assert_eq!(p["profile"], "P8"); + } + + #[test] + fn dv_detection_dovi_without_profile_gives_none() { + // DV detected ("dovi") but no profile info → unknown → none. + let p = plan(r#"{"stream":{"name":"4K DoVi 5.1"},"url":"https://cdn.example/f.mkv","fallbackMode":"auto"}"#); + assert_eq!(p["action"], "none"); + assert_eq!(p["reason"], "unknown_profile_no_safe_fallback"); + } + + #[test] + fn dv_detection_standalone_dv_without_profile_gives_none() { + // "[DV]" detected but no profile info → none. + let p = plan(r#"{"stream":{"name":"[4K] [DV] [HDR10+]"},"url":"https://cdn.example/f.mkv","fallbackMode":"auto"}"#); + assert_eq!(p["action"], "none"); + assert_eq!(p["reason"], "unknown_profile_no_safe_fallback"); + } + + #[test] + fn dv_detection_dvhe_fourcc_in_name_gives_profile_p7() { + let p = plan(r#"{"stream":{"name":"dvhe.07.06 BDRemux"},"url":"https://cdn.example/f.mkv","fallbackMode":"auto"}"#); + assert_ne!(p["action"], "none"); + assert_eq!(p["profile"], "P7"); + } + + #[test] + fn dv_detection_dvhe_08_01_in_name_gives_p8_1() { + let p = plan(r#"{"stream":{"name":"dvhe.08.01 Remux"},"url":"https://cdn.example/f.mkv","fallbackMode":"auto"}"#); + assert_eq!(p["action"], "dvcc_strip"); + assert_eq!(p["profile"], "P8.1"); + assert_eq!(p["safety"], "low"); + } + + #[test] + fn dv_detection_no_false_positive_from_dvd() { + let p = plan(r#"{"stream":{"name":"DVD Rip 1080p"},"url":"https://cdn.example/f.mkv","fallbackMode":"auto"}"#); + assert_eq!(p["action"], "none"); + assert_eq!(p["reason"], "not_dv"); + } + + #[test] + fn dv_detection_no_false_positive_from_hdvd() { + let p = plan(r#"{"stream":{"name":"HDVD Edition"},"url":"https://cdn.example/f.mkv","fallbackMode":"auto"}"#); + assert_eq!(p["action"], "none"); + } + + #[test] + fn dv_detection_explicit_boolean_flag_with_profile() { + let p = plan(r#"{"stream":{"dv":true,"dvProfile":8,"dvCompatId":1,"name":"4K HDR"},"url":"https://cdn.example/f.mkv","fallbackMode":"auto"}"#); + assert_ne!(p["action"], "none"); + assert_eq!(p["profile"], "P8.1"); + } + + #[test] + fn dv_detection_filename_without_profile_gives_none() { + // DV keyword in filename but no profile → safe default is none. + let p = plan(r#"{"stream":{"name":"4K HDR","effectiveFilename":"Movie.2023.UHD.DV.HEVC.mkv"},"url":"https://cdn.example/f.mkv","fallbackMode":"auto"}"#); + assert_eq!(p["action"], "none"); + assert_eq!(p["reason"], "unknown_profile_no_safe_fallback"); + } + + #[test] + fn dv_detection_dvhe_codec_in_filename_gives_profile() { + let p = plan(r#"{"stream":{"effectiveFilename":"Movie.dvhe.07.06.mkv"},"url":"https://cdn.example/f.mkv","fallbackMode":"auto"}"#); + assert_ne!(p["action"], "none"); + assert_eq!(p["profile"], "P7"); + } + + // ── Realistic sample stream matrix ───────────────────────────────────────── + // + // These tests mirror what real Stremio addon stream objects look like, + // verifying the full plan output (action, profile, compatibility, safety). + + #[test] + fn sample_p5_dvonly_no_fallback() { + // P5 is HEVC single-layer with no HDR base. Stripping DVCC would expose + // a DV-only bitstream to an HDR10 decoder → broken colour. Never rewrite. + let p = plan(r#"{ + "stream": { + "name": "AETHER | 4K | Dolby Vision | DD+ Atmos", + "description": "📺 4K | 🎬 dvhe.05.06 | 🔊 DD+ Atmos", + "dvProfile": 5 + }, + "url": "https://debrid.example/movie.mkv", + "fallbackMode": "auto", + "deviceHasDvDecoder": false + }"#); + assert_eq!(p["action"], "none"); + assert_eq!(p["reason"], "no_hdr_base_layer"); + assert_eq!(p["profile"], "P5"); + let limitations = p["limitations"].as_array().unwrap(); + assert!(limitations.iter().any(|l| l.as_str().unwrap().contains("p4_p5"))); + } + + #[test] + fn sample_p7_dual_layer_hdr10_fallback() { + // P7 BL+EL: stripping DVCC reveals the HDR10 base layer. Medium risk — + // RPU NALs remain in-stream but HEVC decoders ignore them. + let p = plan(r#"{ + "stream": { + "name": "FLUX | 4K | dvhe.07.06 | Atmos", + "description": "HDR10 + Dolby Vision P7 BL+EL remux", + "dvProfile": 7 + }, + "url": "https://realdebrid.com/dl/movie2024.mkv", + "fallbackMode": "auto", + "deviceHasDvDecoder": false, + "deviceHasDvDisplay": false + }"#); + assert_eq!(p["action"], "dvcc_strip"); + assert_eq!(p["profile"], "P7"); + assert_eq!(p["compatibility"], "HDR10"); + assert_eq!(p["safety"], "medium"); + let limitations = p["limitations"].as_array().unwrap(); + assert!(limitations.iter().any(|l| l.as_str().unwrap().contains("does_not_convert_bitstream"))); + } + + #[test] + fn sample_p8_1_single_layer_low_risk_fallback() { + // P8.1 has an HDR10-compatible base layer encoded into the single HEVC stream. + // Stripping DVCC gives clean HDR10 output. Lowest-risk rewrite. + let p = plan(r#"{ + "stream": { + "name": "HDMUX | 4K | dvhe.08.01 | TrueHD Atmos", + "dvProfile": 8, + "dvCompatId": 1 + }, + "url": "https://debrid.example/Movie.2023.2160p.DV.HEVC.mkv", + "fallbackMode": "auto", + "deviceHasDvDecoder": false + }"#); + assert_eq!(p["action"], "dvcc_strip"); + assert_eq!(p["profile"], "P8.1"); + assert_eq!(p["compatibility"], "HDR10"); + assert_eq!(p["safety"], "low"); + } + + #[test] + fn sample_p8_4_hlg_base_not_hdr10() { + // P8.4 has an HLG base layer, not HDR10. Rewriting it as HDR10 would + // produce incorrect colour. The compatibility field must reflect HLG. + let p = plan(r#"{ + "stream": { + "name": "BBC iPlayer | 4K | Dolby Vision HLG | AAC", + "dvProfile": 8, + "dvCompatId": 4 + }, + "url": "https://cdn.example/show_ep01.mkv", + "fallbackMode": "auto", + "deviceHasDvDecoder": false + }"#); + assert_eq!(p["action"], "dvcc_strip"); + assert_eq!(p["profile"], "P8.4"); + assert_eq!(p["compatibility"], "HLG"); + assert_ne!(p["compatibility"], "HDR10", + "P8.4 has HLG base, must not be labelled HDR10"); + assert_eq!(p["safety"], "medium"); + } + + #[test] + fn sample_unknown_profile_from_addon_with_only_dv_keyword() { + // Many addons only set a "Dolby Vision" label without specifying the + // profile. Without profile info the only safe action is none. + let p = plan(r#"{ + "stream": { + "name": "4K | Dolby Vision | DD+ Atmos", + "description": "UHD Remux" + }, + "url": "https://debrid.example/movie.mkv", + "fallbackMode": "auto", + "deviceHasDvDecoder": false + }"#); + assert_eq!(p["action"], "none"); + assert_eq!(p["reason"], "unknown_profile_no_safe_fallback"); + let limitations = p["limitations"].as_array().unwrap(); + assert!(limitations.iter().any(|l| l.as_str().unwrap().contains("set_dvProfile_field"))); + } + + #[test] + fn sample_p7_rpu_convert_on_raw_hevc_dv8_mode() { + // Raw Annex-B HEVC + P7 + dv8 mode → live RPU conversion. The only + // case where rpu_convert is emitted instead of dvcc_strip. + let p = plan(r#"{ + "stream": { + "name": "RAW HEVC | 4K | dvhe.07.06", + "dvProfile": 7 + }, + "url": "https://cdn.example/stream.hevc", + "fallbackMode": "dv8", + "deviceHasDvDecoder": false + }"#); + assert_eq!(p["action"], "rpu_convert"); + assert_eq!(p["profile"], "P7"); + assert_eq!(p["compatibility"], "DV8"); + assert_eq!(p["rpuMode"], 2); + } + + // ── convert_dv81 mode ────────────────────────────────────────────────────── + + #[test] + fn convert_dv81_p7_mkv_decoder_no_display_returns_rpu_convert() { + // Decoder present, no DV display: MKV now supported via EBML RPU rewriter. + let p = plan(r#"{"stream":{"dvProfile":7},"url":"https://cdn.example/movie.mkv","fallbackMode":"convert_dv81","deviceHasDvDecoder":true,"deviceHasDvDisplay":false}"#); + assert_eq!(p["action"], "rpu_convert"); + assert_eq!(p["reason"], "p7_rpu_convert_to_dv81"); + } + + #[test] + fn convert_dv81_p7_mp4_decoder_no_display_returns_rpu_convert() { + let p = plan(r#"{"stream":{"dvProfile":7},"url":"https://cdn.example/movie.mp4","fallbackMode":"convert_dv81","deviceHasDvDecoder":true,"deviceHasDvDisplay":false}"#); + assert_eq!(p["action"], "rpu_convert"); + assert_eq!(p["reason"], "p7_rpu_convert_to_dv81"); + } + + #[test] + fn convert_dv81_p7_raw_hevc_decoder_no_display_returns_rpu_convert() { + let p = plan(r#"{"stream":{"dvProfile":7},"url":"https://cdn.example/movie.hevc","fallbackMode":"convert_dv81","deviceHasDvDecoder":true,"deviceHasDvDisplay":false}"#); + assert_eq!(p["action"], "rpu_convert"); + assert_eq!(p["reason"], "p7_rpu_convert_to_dv81"); + } + + #[test] + fn convert_dv81_decoder_and_display_returns_native_passthrough() { + // Full DV device → native, no proxy needed. + let p = plan(r#"{"stream":{"dvProfile":7},"url":"https://cdn.example/movie.mp4","fallbackMode":"convert_dv81","deviceHasDvDecoder":true,"deviceHasDvDisplay":true}"#); + assert_eq!(p["action"], "none"); + assert_eq!(p["reason"], "hw_dv_decoder"); + } + + #[test] + fn convert_dv81_no_decoder_falls_back_to_dvcc_strip() { + // No DV decoder → same as Auto: strip to HDR10. + let p = plan(r#"{"stream":{"dvProfile":7},"url":"https://cdn.example/movie.mp4","fallbackMode":"convert_dv81","deviceHasDvDecoder":false,"deviceHasDvDisplay":false}"#); + assert_eq!(p["action"], "dvcc_strip"); + } + + #[test] + fn convert_dv81_hls_still_deferred_to_manifest_rewrite() { + let p = plan(r#"{"stream":{"dvProfile":7},"url":"https://cdn.example/index.m3u8","fallbackMode":"convert_dv81","deviceHasDvDecoder":true,"deviceHasDvDisplay":false}"#); + assert_eq!(p["action"], "none"); + assert_eq!(p["reason"], "manifest_handled"); + } + + #[test] + fn sample_hls_stream_always_deferred_to_manifest_rewrite() { + // HLS streams are handled by the OkHttp interceptor regardless of profile. + // The proxy must never be activated for .m3u8 URLs. + let p = plan(r#"{ + "stream": { + "name": "Apple TV+ | 4K | dvhe.08.01", + "dvProfile": 8, + "dvCompatId": 1 + }, + "url": "https://cdn.example/master.m3u8", + "fallbackMode": "auto", + "deviceHasDvDecoder": false + }"#); + assert_eq!(p["action"], "none"); + assert_eq!(p["reason"], "manifest_handled"); + } +} diff --git a/src/player_scrobble.rs b/src/player_scrobble.rs new file mode 100644 index 0000000..946e15e --- /dev/null +++ b/src/player_scrobble.rs @@ -0,0 +1,95 @@ +const SCROBBLE_START_PROGRESS_PERCENT: f32 = 0.2; +const SCROBBLE_STOP_PROGRESS_PERCENT: f32 = 80.0; +const DURABLE_SCROBBLE_MIN_PROGRESS_PERCENT: f32 = 1.0; +const PERIODIC_PROGRESS_SAVE_MS: i64 = 30_000; +const DISPOSAL_PROGRESS_SAVE_MIN_MS: i64 = 5_000; + +pub(crate) fn progress_percent(position_ms: i64, duration_ms: i64) -> f32 { + if duration_ms <= 0 { + return 0.0; + } + ((position_ms as f32 / duration_ms as f32) * 100.0).clamp(0.0, 100.0) +} + +pub(crate) fn should_send_start( + token: Option<&str>, + is_playing: bool, + has_scrobbled_start: bool, + progress: f32, +) -> bool { + has_token(token) + && is_playing + && !has_scrobbled_start + && progress > SCROBBLE_START_PROGRESS_PERCENT +} + +pub(crate) fn should_mark_stopped(has_scrobbled_stop: bool, progress: f32) -> bool { + !has_scrobbled_stop && progress >= SCROBBLE_STOP_PROGRESS_PERCENT +} + +pub(crate) fn should_queue_pause( + token: Option<&str>, + was_play_when_ready: bool, + has_scrobbled_start: bool, + has_scrobbled_stop: bool, +) -> bool { + has_token(token) && was_play_when_ready && has_scrobbled_start && !has_scrobbled_stop +} + +pub(crate) fn should_enqueue_durable(action: &str, token: Option<&str>, progress: f32) -> bool { + if !has_token(token) { + return false; + } + (action != "pause" && action != "stop") || progress >= DURABLE_SCROBBLE_MIN_PROGRESS_PERCENT +} + +pub(crate) fn should_save_periodic_progress( + is_playing: bool, + now_ms: i64, + last_saved_at_ms: i64, +) -> bool { + is_playing && now_ms - last_saved_at_ms > PERIODIC_PROGRESS_SAVE_MS +} + +pub(crate) fn should_save_on_dispose(position_ms: i64) -> bool { + position_ms > DISPOSAL_PROGRESS_SAVE_MIN_MS +} + +fn has_token(token: Option<&str>) -> bool { + token.is_some_and(|value| !value.trim().is_empty()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn progress_percent_is_clamped_and_zero_for_missing_duration() { + assert_eq!(progress_percent(1_000, 0), 0.0); + assert_eq!(progress_percent(5_000, 10_000), 50.0); + assert_eq!(progress_percent(20_000, 10_000), 100.0); + } + + #[test] + fn start_requires_token_playing_and_initial_progress() { + assert!(!should_send_start(None, true, false, 1.0)); + assert!(!should_send_start(Some("token"), false, false, 1.0)); + assert!(!should_send_start(Some("token"), true, true, 1.0)); + assert!(!should_send_start(Some("token"), true, false, 0.1)); + assert!(should_send_start(Some("token"), true, false, 0.3)); + } + + #[test] + fn pause_stop_and_save_thresholds_match_platform_contract() { + assert!(!should_mark_stopped(false, 79.9)); + assert!(should_mark_stopped(false, 80.0)); + assert!(!should_mark_stopped(true, 90.0)); + assert!(!should_enqueue_durable("pause", Some("token"), 0.5)); + assert!(should_enqueue_durable("pause", Some("token"), 1.0)); + assert!(should_enqueue_durable("start", Some("token"), 0.1)); + assert!(!should_save_periodic_progress(true, 30_000, 0)); + assert!(should_save_periodic_progress(true, 30_001, 0)); + assert!(!should_save_on_dispose(5_000)); + assert!(should_save_on_dispose(5_001)); + } +} diff --git a/src/profile_contract.rs b/src/profile_contract.rs new file mode 100644 index 0000000..d435f2e --- /dev/null +++ b/src/profile_contract.rs @@ -0,0 +1,469 @@ +use serde::Deserialize; +use serde_json::{json, Value}; + +const DEFAULT_ADDON_URL: &str = "https://v3-cinemeta.strem.io/manifest.json"; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ActiveProfileRequest { + #[serde(default)] + profiles: Vec, + #[serde(default)] + stored_active_id: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct TokenMergeRequest { + profile: Value, + auth_result: Value, + provider: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct DefaultProfileRequest { + #[serde(default)] + id: Option, + #[serde(default)] + email: Option, + #[serde(default)] + auth_key: Option, + #[serde(default)] + language: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SettingsMigrationRequest { + #[serde(default)] + raw: Value, + #[serde(default)] + schema_version: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct AvatarDefaultRequest { + #[serde(default)] + profile: Value, + #[serde(default)] + catalog: Vec, +} + +pub(crate) fn active_profile_plan_json(request_json: &str) -> Option { + let request = serde_json::from_str::(request_json).ok()?; + if request.profiles.is_empty() { + return serde_json::to_string(&json!({ + "activeId": "guest", + "shouldCreateDefault": true, + "activeProfile": Value::Null + })) + .ok(); + } + let stored = request.stored_active_id.as_deref().unwrap_or("").trim(); + let active = if stored.is_empty() || stored == "guest" { + request + .profiles + .first() + .cloned() + .unwrap_or(Value::Null) + } else { + request + .profiles + .iter() + .find(|p| p.get("id").and_then(Value::as_str) == Some(stored)) + .cloned() + .or_else(|| request.profiles.first().cloned()) + .unwrap_or(Value::Null) + }; + let active_id = active + .get("id") + .and_then(Value::as_str) + .unwrap_or("guest") + .to_string(); + serde_json::to_string(&json!({ + "activeId": active_id, + "shouldCreateDefault": false, + "activeProfile": active + })) + .ok() +} + +pub(crate) fn token_merge_plan_json(request_json: &str) -> Option { + let request = serde_json::from_str::(request_json).ok()?; + let mut profile = request.profile.clone(); + let auth = &request.auth_result; + let provider = request.provider.as_str(); + + if profile.is_null() || !profile.is_object() { + profile = json!({}); + } + + let obj = profile.as_object_mut()?; + + match provider { + "trakt" => { + let token = auth.get("accessToken").or_else(|| auth.get("access_token")); + let refresh = auth.get("refreshToken").or_else(|| auth.get("refresh_token")); + let expires_at = auth + .get("expiresAt") + .or_else(|| auth.get("expires_at")) + .or_else(|| auth.get("traktTokenExpiresAt")); + if let Some(t) = token { + obj.insert("traktAccessToken".to_string(), t.clone()); + } + if let Some(r) = refresh { + obj.insert("traktRefreshToken".to_string(), r.clone()); + } + if let Some(e) = expires_at { + obj.insert("traktTokenExpiresAt".to_string(), e.clone()); + } + obj.insert("traktLastSyncAt".to_string(), Value::Null); + } + "mal" => { + let token = auth.get("accessToken").or_else(|| auth.get("access_token")); + let refresh = auth.get("refreshToken").or_else(|| auth.get("refresh_token")); + if let Some(t) = token { + obj.insert("malAccessToken".to_string(), t.clone()); + } + if let Some(r) = refresh { + obj.insert("malRefreshToken".to_string(), r.clone()); + } + } + "simkl" => { + let token = auth.get("accessToken").or_else(|| auth.get("access_token")); + if let Some(t) = token { + obj.insert("simklAccessToken".to_string(), t.clone()); + } + } + "stremio" | "account" => { + if let Some(auth_key) = auth.get("authKey").or_else(|| auth.get("apiKey")) { + obj.insert("authKey".to_string(), auth_key.clone()); + } + if let Some(id) = auth.get("id") { + obj.insert("id".to_string(), id.clone()); + } + if let Some(email) = auth.get("email") { + obj.insert("email".to_string(), email.clone()); + } + obj.insert("isGuest".to_string(), json!(false)); + } + _ => {} + } + + serde_json::to_string(&json!({ + "mergedProfile": profile, + "provider": provider + })) + .ok() +} + +pub(crate) fn profile_default_seed_json(request_json: &str) -> Option { + let request = serde_json::from_str::(request_json).ok()?; + let id = request + .id + .filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| "guest".to_string()); + let email = request.email.unwrap_or_default(); + let auth_key = request.auth_key.unwrap_or_default(); + let language = request + .language + .filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| "en".to_string()); + let is_guest = id == "guest" || auth_key.is_empty(); + serde_json::to_string(&json!({ + "id": id, + "email": email, + "authKey": auth_key, + "isGuest": is_guest, + "language": language, + "localAddons": [DEFAULT_ADDON_URL], + "disabledLocalAddons": [] + })) + .ok() +} + +pub(crate) fn profile_settings_migration_plan_json(request_json: &str) -> Option { + let request = serde_json::from_str::(request_json).ok()?; + let mut profile = request.raw.clone(); + let schema_version = request.schema_version.unwrap_or(0); + + if profile.is_null() || !profile.is_object() { + return serde_json::to_string(&json!({ + "migratedProfile": Value::Null, + "appliedMigrations": [] + })) + .ok(); + } + + let obj = profile.as_object_mut()?; + let mut applied = Vec::::new(); + + // Migration: flatten nested externalAccounts into top-level fields (v1 -> v2) + if schema_version < 2 { + if let Some(ext) = obj.remove("externalAccounts") { + if let Some(ext_obj) = ext.as_object() { + for (k, v) in ext_obj { + obj.entry(k.clone()).or_insert(v.clone()); + } + applied.push("flatten_external_accounts".to_string()); + } + } + } + + // Migration: flatten nested addonSettings into top-level localAddons/disabledLocalAddons + if schema_version < 2 { + if let Some(addon_settings) = obj.remove("addonSettings") { + if let Some(addon_obj) = addon_settings.as_object() { + if let Some(local) = addon_obj.get("localAddons") { + obj.entry("localAddons".to_string()).or_insert(local.clone()); + } + if let Some(disabled) = addon_obj.get("disabledLocalAddons") { + obj.entry("disabledLocalAddons".to_string()) + .or_insert(disabled.clone()); + } + applied.push("flatten_addon_settings".to_string()); + } + } + } + + // Migration: flatten nested subtitleSettings + if schema_version < 2 { + if let Some(sub_settings) = obj.remove("subtitleSettings") { + if let Some(sub_obj) = sub_settings.as_object() { + let field_map = [ + ("size", "subtitleSize"), + ("color", "subtitleColor"), + ("backgroundColor", "subtitleBackgroundColor"), + ("outlineColor", "subtitleOutlineColor"), + ("textOpacity", "subtitleTextOpacity"), + ("backgroundOpacity", "subtitleBackgroundOpacity"), + ("outlineOpacity", "subtitleOutlineOpacity"), + ("preferredLanguage", "preferredSubtitleLanguage"), + ("secondaryLanguage", "secondarySubtitleLanguage"), + ("shadow", "subtitleShadow"), + ("autoEnable", "autoEnableSubtitles"), + ]; + for (src, dst) in field_map { + if let Some(v) = sub_obj.get(src) { + obj.entry(dst.to_string()).or_insert(v.clone()); + } + } + applied.push("flatten_subtitle_settings".to_string()); + } + } + } + + // Migration: flatten nested playbackSettings + if schema_version < 2 { + if let Some(pb_settings) = obj.remove("playbackSettings") { + if let Some(pb_obj) = pb_settings.as_object() { + for (k, v) in pb_obj { + obj.entry(k.clone()).or_insert(v.clone()); + } + applied.push("flatten_playback_settings".to_string()); + } + } + } + + // Migration: flatten nested torrentSettings + if schema_version < 2 { + if let Some(torr_settings) = obj.remove("torrentSettings") { + if let Some(torr_obj) = torr_settings.as_object() { + let field_map = [ + ("wifiOnly", "torrentWifiOnly"), + ("maxConnections", "torrentMaxConnections"), + ("speedPreset", "torrentSpeedPreset"), + ("cachePreset", "torrentCachePreset"), + ]; + for (src, dst) in field_map { + if let Some(v) = torr_obj.get(src) { + obj.entry(dst.to_string()).or_insert(v.clone()); + } + } + applied.push("flatten_torrent_settings".to_string()); + } + } + } + + // Migration: flatten nested appearanceSettings + if schema_version < 2 { + if let Some(app_settings) = obj.remove("appearanceSettings") { + if let Some(app_obj) = app_settings.as_object() { + for (k, v) in app_obj { + obj.entry(k.clone()).or_insert(v.clone()); + } + applied.push("flatten_appearance_settings".to_string()); + } + } + } + + // Migration: flatten nested homeFeedSettings + if schema_version < 2 { + if let Some(feed_settings) = obj.remove("homeFeedSettings") { + if let Some(feed_obj) = feed_settings.as_object() { + for (k, v) in feed_obj { + if k == "libraryCollections" + && v.as_array().is_some_and(|items| !items.is_empty()) + && obj + .get(k) + .and_then(Value::as_array) + .is_none_or(|items| items.is_empty()) + { + obj.insert(k.clone(), v.clone()); + continue; + } + obj.entry(k.clone()).or_insert(v.clone()); + } + applied.push("flatten_home_feed_settings".to_string()); + } + } + } + + // Ensure localAddons always has at least the default addon + { + let has_local_addons = obj + .get("localAddons") + .and_then(Value::as_array) + .is_some_and(|arr| !arr.is_empty()); + if !has_local_addons { + obj.insert( + "localAddons".to_string(), + json!([DEFAULT_ADDON_URL]), + ); + applied.push("ensure_default_addon".to_string()); + } + } + + serde_json::to_string(&json!({ + "migratedProfile": profile, + "appliedMigrations": applied, + "schemaVersion": 2 + })) + .ok() +} + +pub(crate) fn profile_avatar_default_json(request_json: &str) -> Option { + let request = serde_json::from_str::(request_json).ok()?; + let existing = request + .profile + .get("avatarUrl") + .and_then(Value::as_str) + .filter(|s| !s.trim().is_empty()); + if let Some(url) = existing { + return serde_json::to_string(&json!({ + "avatarUrl": url, + "fromCatalog": false + })) + .ok(); + } + let first_catalog = request + .catalog + .first() + .and_then(|e| e.get("url").and_then(Value::as_str)) + .map(ToString::to_string); + serde_json::to_string(&json!({ + "avatarUrl": first_catalog, + "fromCatalog": first_catalog.is_some() + })) + .ok() +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::Value; + + #[test] + fn active_profile_plan_returns_first_when_no_stored_id() { + let result: Value = serde_json::from_str( + &active_profile_plan_json( + r#"{"profiles":[{"id":"p1"},{"id":"p2"}]}"#, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(result["activeId"], "p1"); + assert_eq!(result["shouldCreateDefault"], false); + } + + #[test] + fn active_profile_plan_creates_default_when_profiles_empty() { + let result: Value = serde_json::from_str( + &active_profile_plan_json(r#"{"profiles":[]}"#).unwrap(), + ) + .unwrap(); + assert_eq!(result["activeId"], "guest"); + assert_eq!(result["shouldCreateDefault"], true); + } + + #[test] + fn active_profile_plan_selects_stored_id() { + let result: Value = serde_json::from_str( + &active_profile_plan_json( + r#"{"profiles":[{"id":"p1"},{"id":"p2"}],"storedActiveId":"p2"}"#, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(result["activeId"], "p2"); + } + + #[test] + fn token_merge_plan_merges_trakt_tokens_into_profile() { + let result: Value = serde_json::from_str( + &token_merge_plan_json( + r#"{"profile":{"id":"p1","email":"u@example.com"},"authResult":{"accessToken":"tok","refreshToken":"ref","expiresAt":999},"provider":"trakt"}"#, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(result["mergedProfile"]["traktAccessToken"], "tok"); + assert_eq!(result["mergedProfile"]["traktRefreshToken"], "ref"); + assert_eq!(result["mergedProfile"]["traktTokenExpiresAt"], 999); + } + + #[test] + fn settings_migration_flattens_nested_external_accounts() { + let result: Value = serde_json::from_str( + &profile_settings_migration_plan_json( + r#"{"raw":{"id":"p1","externalAccounts":{"traktAccessToken":"tok"}},"schemaVersion":1}"#, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!( + result["migratedProfile"]["traktAccessToken"], + "tok" + ); + assert!(result["appliedMigrations"] + .as_array() + .unwrap() + .iter() + .any(|m| m == "flatten_external_accounts")); + } + + #[test] + fn settings_migration_keeps_nested_library_collections_when_top_level_is_empty() { + let result: Value = serde_json::from_str( + &profile_settings_migration_plan_json( + r#"{"raw":{"id":"p1","libraryCollections":[],"homeFeedSettings":{"libraryCollections":[{"id":"c1","title":"Collection"}]}},"schemaVersion":1}"#, + ) + .unwrap(), + ) + .unwrap(); + + assert_eq!(result["migratedProfile"]["libraryCollections"][0]["id"], "c1"); + } + + #[test] + fn default_seed_produces_guest_profile_with_default_addon() { + let result: Value = + serde_json::from_str(&profile_default_seed_json("{}").unwrap()).unwrap(); + assert_eq!(result["id"], "guest"); + assert_eq!(result["isGuest"], true); + let addons = result["localAddons"].as_array().unwrap(); + assert!(!addons.is_empty()); + } +} diff --git a/src/profile_prefs.rs b/src/profile_prefs.rs new file mode 100644 index 0000000..5e26224 --- /dev/null +++ b/src/profile_prefs.rs @@ -0,0 +1,398 @@ +use serde::Serialize; +use serde_json::Value; + +pub(crate) fn safe_player_buffer_cache_mb(value: Option) -> i32 { + value.unwrap_or(100).clamp(100, 2000) +} + +pub(crate) fn safe_dolby_vision_fallback_mode( + mode: Option<&str>, + legacy_dv7_fallback: Option, + legacy_dv7_to_dv8_fallback: Option, +) -> &'static str { + match mode { + Some("auto") => "auto", + Some("convert_dv81") => "convert_dv81", + Some("hdr10") => "hdr10", + Some("dv8") => "dv8", + Some("off") => "off", + _ if legacy_dv7_to_dv8_fallback == Some(true) => "dv8", + _ if legacy_dv7_fallback != Some(false) => "hdr10", + _ => "off", + } +} + +pub(crate) fn safe_stream_source_selection_mode(mode: Option<&str>) -> &'static str { + match mode { + Some("first") => "first", + Some("regex") => "regex", + _ => "manual", + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct ProfileSafePrefs { + language: String, + subtitle_size_percent: f32, + subtitle_size: f32, + subtitle_color: i64, + subtitle_background_color: i64, + subtitle_outline_color: i64, + subtitle_text_opacity: f32, + subtitle_background_opacity: f32, + subtitle_outline_opacity: f32, + preferred_subtitle_language: String, + preferred_audio_language: String, + secondary_subtitle_language: String, + secondary_audio_language: String, + stable_volume: bool, + ambient_light: bool, + force_software_audio: bool, + preferred_player: String, + card_layout: String, + continue_watching_layout: String, + continue_watching_artwork: String, + continue_watching_enabled: bool, + resolved_continue_watching_layout: String, + subtitle_shadow: bool, + auto_enable_subtitles: bool, + auto_skip_intro: bool, + auto_play_next_episode: bool, + next_episode_threshold_percent: f32, + watched_threshold_percent: f32, + seek_forward_seconds: i64, + seek_backward_seconds: i64, + player_buffer_cache_mb: i32, + player_forward_buffer_seconds: i64, + player_back_buffer_seconds: i64, + timezone_conversion_enabled: bool, + torrent_wifi_only: bool, + torrent_max_connections: i64, + torrent_speed_preset: String, + torrent_cache_preset: String, + app_theme: String, + accent_color_argb: i64, + card_corner_preset: String, + interface_density: String, + amoled_mode: bool, + poster_width_preset: String, + poster_landscape_mode: bool, + poster_hide_titles: bool, + detail_episode_view_mode: String, + animations_enabled: bool, + reduce_motion: bool, + start_page: String, + notifications_enabled: bool, + alert_new_episodes: bool, + automatic_updates: bool, + background_playback: bool, + picture_in_picture: bool, + playback_speed: f32, + hold_to_speed_enabled: bool, + hold_speed: f32, + dolby_vision_fallback_mode: String, + dv7_to_dv8_fallback: bool, + dv7_fallback: bool, + tunneled_playback: bool, + use_intro_db: bool, + use_ani_skip: bool, + default_quality: String, + mobile_data_usage: String, + hdr_playback: bool, + resume_playback: bool, + autoplay_mode: String, + stream_source_selection_mode: String, + stream_source_regex_pattern: String, + try_binge_group: bool, + show_hero_section: bool, + trakt_token_expires_at: i64, + trakt_last_sync_at: i64, + trakt_last_synced_items: i64, + trakt_last_continue_watching_count: i64, + trakt_last_watchlist_count: i64, +} + +pub(crate) fn profile_safe_prefs_json(profile_json: &str) -> Option { + let profile = serde_json::from_str::(profile_json).ok()?; + serde_json::to_string(&profile_safe_prefs(&profile)).ok() +} + +fn profile_safe_prefs(profile: &Value) -> ProfileSafePrefs { + let subtitle_size_percent = + safe_subtitle_size_percent(number(profile, "subtitleSize").unwrap_or(100.0) as f32); + let card_layout = safe_card_layout(text(profile, "cardLayout")); + let continue_watching_layout = + safe_continue_watching_layout(text(profile, "continueWatchingLayout")); + let dolby_mode = safe_dolby_vision_fallback_mode( + text(profile, "dolbyVisionFallbackMode"), + bool_value(profile, "dv7Fallback"), + bool_value(profile, "dv7ToDv8Fallback"), + ) + .to_string(); + + ProfileSafePrefs { + language: text(profile, "language").unwrap_or("en").to_string(), + subtitle_size_percent, + subtitle_size: 20.0 * (subtitle_size_percent / 100.0), + subtitle_color: int(profile, "subtitleColor").unwrap_or(0xFFFF_FFFFu32 as i32 as i64), + subtitle_background_color: int(profile, "subtitleBackgroundColor") + .unwrap_or(0x8000_0000u32 as i32 as i64), + subtitle_outline_color: int(profile, "subtitleOutlineColor") + .unwrap_or(0xFF00_0000u32 as i32 as i64), + subtitle_text_opacity: number(profile, "subtitleTextOpacity").unwrap_or(1.0) as f32, + subtitle_background_opacity: number(profile, "subtitleBackgroundOpacity").unwrap_or(0.5) + as f32, + subtitle_outline_opacity: number(profile, "subtitleOutlineOpacity").unwrap_or(1.0) as f32, + preferred_subtitle_language: text(profile, "preferredSubtitleLanguage") + .unwrap_or("none") + .to_string(), + preferred_audio_language: text(profile, "preferredAudioLanguage") + .unwrap_or("none") + .to_string(), + secondary_subtitle_language: text(profile, "secondarySubtitleLanguage") + .unwrap_or("none") + .to_string(), + secondary_audio_language: text(profile, "secondaryAudioLanguage") + .unwrap_or("none") + .to_string(), + stable_volume: bool_value(profile, "stableVolume").unwrap_or(false), + ambient_light: bool_value(profile, "ambientLight").unwrap_or(true), + force_software_audio: bool_value(profile, "forceSoftwareAudio").unwrap_or(false), + preferred_player: safe_preferred_player(text(profile, "preferredPlayer")).to_string(), + card_layout: card_layout.clone(), + continue_watching_layout: continue_watching_layout.clone(), + continue_watching_artwork: text(profile, "continueWatchingArtwork") + .unwrap_or("episode") + .to_string(), + continue_watching_enabled: bool_value(profile, "continueWatchingEnabled").unwrap_or(true), + resolved_continue_watching_layout: if continue_watching_layout == "inherit" { + card_layout + } else { + continue_watching_layout + }, + subtitle_shadow: bool_value(profile, "subtitleShadow").unwrap_or(true), + auto_enable_subtitles: bool_value(profile, "autoEnableSubtitles").unwrap_or(true), + auto_skip_intro: bool_value(profile, "autoSkipIntro").unwrap_or(false), + auto_play_next_episode: bool_value(profile, "autoPlayNextEpisode").unwrap_or(true), + next_episode_threshold_percent: number(profile, "nextEpisodeThresholdPercent") + .unwrap_or(90.0) + .clamp(50.0, 99.0) as f32, + watched_threshold_percent: number(profile, "watchedThresholdPercent") + .unwrap_or(80.0) + .clamp(50.0, 99.0) as f32, + seek_forward_seconds: int(profile, "seekForwardSeconds").unwrap_or(10), + seek_backward_seconds: int(profile, "seekBackwardSeconds").unwrap_or(10), + player_buffer_cache_mb: safe_player_buffer_cache_mb( + int(profile, "playerBufferCacheMb").map(|v| v as i32), + ), + player_forward_buffer_seconds: int(profile, "playerForwardBufferSeconds") + .unwrap_or(120) + .clamp(30, 600), + player_back_buffer_seconds: int(profile, "playerBackBufferSeconds") + .unwrap_or(30) + .clamp(0, 300), + timezone_conversion_enabled: true, + torrent_wifi_only: bool_value(profile, "torrentWifiOnly").unwrap_or(false), + torrent_max_connections: int(profile, "torrentMaxConnections").unwrap_or(60), + torrent_speed_preset: text(profile, "torrentSpeedPreset") + .unwrap_or("default") + .to_string(), + torrent_cache_preset: text(profile, "torrentCachePreset") + .unwrap_or("auto") + .to_string(), + app_theme: text(profile, "appTheme").unwrap_or("dark").to_string(), + accent_color_argb: int(profile, "accentColorArgb").unwrap_or(0xFFFF_FFFFu32 as i32 as i64), + card_corner_preset: text(profile, "cardCornerPreset") + .unwrap_or("medium") + .to_string(), + interface_density: text(profile, "interfaceDensity") + .unwrap_or("medium") + .to_string(), + amoled_mode: bool_value(profile, "amoledMode").unwrap_or(false), + poster_width_preset: text(profile, "posterWidthPreset") + .unwrap_or("medium") + .to_string(), + poster_landscape_mode: bool_value(profile, "posterLandscapeMode").unwrap_or(false), + poster_hide_titles: bool_value(profile, "posterHideTitles").unwrap_or(false), + detail_episode_view_mode: safe_detail_episode_view_mode(text( + profile, + "detailEpisodeViewMode", + )) + .to_string(), + animations_enabled: bool_value(profile, "animationsEnabled").unwrap_or(true), + reduce_motion: bool_value(profile, "reduceMotion").unwrap_or(false), + start_page: text(profile, "startPage").unwrap_or("home").to_string(), + notifications_enabled: bool_value(profile, "notificationsEnabled").unwrap_or(true), + alert_new_episodes: bool_value(profile, "alertNewEpisodes").unwrap_or(true), + automatic_updates: bool_value(profile, "automaticUpdates").unwrap_or(true), + background_playback: bool_value(profile, "backgroundPlayback").unwrap_or(false), + picture_in_picture: bool_value(profile, "pictureInPicture").unwrap_or(true), + playback_speed: number(profile, "playbackSpeed").unwrap_or(1.0) as f32, + hold_to_speed_enabled: bool_value(profile, "holdToSpeedEnabled").unwrap_or(true), + hold_speed: number(profile, "holdSpeed").unwrap_or(2.0) as f32, + dv7_to_dv8_fallback: dolby_mode == "dv8", + dv7_fallback: dolby_mode == "hdr10", + dolby_vision_fallback_mode: dolby_mode, + tunneled_playback: bool_value(profile, "tunneledPlayback").unwrap_or(false), + use_intro_db: bool_value(profile, "useIntroDb").unwrap_or(true), + use_ani_skip: bool_value(profile, "useAniSkip").unwrap_or(true), + default_quality: text(profile, "defaultQuality") + .unwrap_or("1080p") + .to_string(), + mobile_data_usage: text(profile, "mobileDataUsage") + .unwrap_or("medium") + .to_string(), + hdr_playback: bool_value(profile, "hdrPlayback").unwrap_or(true), + resume_playback: bool_value(profile, "resumePlayback").unwrap_or(true), + autoplay_mode: text(profile, "autoplayMode") + .unwrap_or("next_episode") + .to_string(), + stream_source_selection_mode: safe_stream_source_selection_mode(text( + profile, + "streamSourceSelectionMode", + )) + .to_string(), + stream_source_regex_pattern: text(profile, "streamSourceRegexPattern") + .unwrap_or("") + .to_string(), + try_binge_group: bool_value(profile, "tryBingeGroup").unwrap_or(false), + show_hero_section: bool_value(profile, "showHeroSection").unwrap_or(true), + trakt_token_expires_at: int(profile, "traktTokenExpiresAt").unwrap_or(0), + trakt_last_sync_at: int(profile, "traktLastSyncAt").unwrap_or(0), + trakt_last_synced_items: int(profile, "traktLastSyncedItems").unwrap_or(0), + trakt_last_continue_watching_count: int(profile, "traktLastContinueWatchingCount") + .unwrap_or(0), + trakt_last_watchlist_count: int(profile, "traktLastWatchlistCount").unwrap_or(0), + } +} + +fn safe_subtitle_size_percent(value: f32) -> f32 { + if value <= 40.0 { + ((value / 20.0) * 100.0).clamp(50.0, 200.0) + } else { + value.clamp(50.0, 200.0) + } +} + +fn safe_preferred_player(value: Option<&str>) -> &'static str { + match value { + Some("mpv") => "mpv", + _ => "exoplayer", + } +} + +fn safe_card_layout(value: Option<&str>) -> String { + match value { + Some("episode") => "horizontal".to_string(), + None => "vertical".to_string(), + Some(value) => value.to_string(), + } +} + +fn safe_continue_watching_layout(value: Option<&str>) -> String { + match value { + Some("episode") => "horizontal".to_string(), + None => "horizontal".to_string(), + Some(value) => value.to_string(), + } +} + +fn safe_detail_episode_view_mode(value: Option<&str>) -> &'static str { + match value { + Some("legacy") => "legacy", + _ => "modern", + } +} + +fn text<'a>(value: &'a Value, key: &str) -> Option<&'a str> { + value + .get(key) + .and_then(Value::as_str) + .filter(|v| !v.is_empty()) +} + +fn bool_value(value: &Value, key: &str) -> Option { + value.get(key).and_then(Value::as_bool) +} + +fn int(value: &Value, key: &str) -> Option { + value.get(key).and_then(Value::as_i64) +} + +fn number(value: &Value, key: &str) -> Option { + value.get(key).and_then(Value::as_f64) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn player_buffer_cache_is_clamped() { + assert_eq!(safe_player_buffer_cache_mb(None), 100); + assert_eq!(safe_player_buffer_cache_mb(Some(50)), 100); + assert_eq!(safe_player_buffer_cache_mb(Some(500)), 500); + assert_eq!(safe_player_buffer_cache_mb(Some(5000)), 2000); + } + + #[test] + fn dolby_vision_mode_keeps_explicit_and_migrates_legacy_flags() { + assert_eq!( + safe_dolby_vision_fallback_mode(Some("auto"), None, None), + "auto" + ); + assert_eq!( + safe_dolby_vision_fallback_mode(None, Some(false), Some(true)), + "dv8", + ); + assert_eq!(safe_dolby_vision_fallback_mode(None, None, None), "hdr10"); + assert_eq!( + safe_dolby_vision_fallback_mode(None, Some(false), Some(false)), + "off", + ); + } + + #[test] + fn stream_source_mode_defaults_to_manual() { + assert_eq!(safe_stream_source_selection_mode(Some("first")), "first"); + assert_eq!(safe_stream_source_selection_mode(Some("regex")), "regex"); + assert_eq!(safe_stream_source_selection_mode(Some("manual")), "manual"); + assert_eq!(safe_stream_source_selection_mode(None), "manual"); + } + + #[test] + fn profile_safe_prefs_match_kotlin_defaults_and_migrations() { + let json = profile_safe_prefs_json( + r#"{ + "language":null, + "subtitleSize":20, + "preferredPlayer":"internal", + "cardLayout":"episode", + "continueWatchingLayout":"inherit", + "playerBufferCacheMb":50, + "playerForwardBufferSeconds":999, + "playerBackBufferSeconds":-1, + "detailEpisodeViewMode":"unknown", + "dolbyVisionFallbackMode":null, + "dv7Fallback":false, + "dv7ToDv8Fallback":true, + "streamSourceSelectionMode":"invalid" + }"#, + ) + .expect("profile safe prefs"); + let value: Value = serde_json::from_str(&json).unwrap(); + + assert_eq!(value["language"], "en"); + assert_eq!(value["subtitleSizePercent"], 100.0); + assert_eq!(value["preferredPlayer"], "exoplayer"); + assert_eq!(value["cardLayout"], "horizontal"); + assert_eq!(value["resolvedContinueWatchingLayout"], "horizontal"); + assert_eq!(value["playerBufferCacheMb"], 100); + assert_eq!(value["playerForwardBufferSeconds"], 600); + assert_eq!(value["playerBackBufferSeconds"], 0); + assert_eq!(value["detailEpisodeViewMode"], "modern"); + assert_eq!(value["dolbyVisionFallbackMode"], "dv8"); + assert_eq!(value["streamSourceSelectionMode"], "manual"); + } +} diff --git a/src/repository_flow.rs b/src/repository_flow.rs new file mode 100644 index 0000000..f9fdf43 --- /dev/null +++ b/src/repository_flow.rs @@ -0,0 +1,276 @@ +use crate::addon_protocol::build_resource_url; +use crate::content_identity::parse_extra_args_json; +use serde::Deserialize; +use serde_json::{json, Map, Value}; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct MetaDetailPlanRequest { + use_configured_addons: bool, + auth_key: String, + #[serde(default)] + local_addons: Vec, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct ManifestFetchDecisionRequest { + force_refresh: bool, + memory_hit: bool, + persistent_hit: bool, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct AddonResourceRequestPlan { + transport_url: String, + resource: String, + content_type: String, + id: String, + #[serde(default)] + extra_args: Map, + #[serde(default)] + extra_raw: String, +} + +pub(crate) fn repository_meta_detail_plan_json(request_json: &str) -> Option { + let request: MetaDetailPlanRequest = serde_json::from_str(request_json).ok()?; + let has_configured_source = !request.auth_key.trim().is_empty() + || request + .local_addons + .iter() + .any(|addon| !addon.trim().is_empty()); + serde_json::to_string(&json!({ + "preferAddonMetaDetail": request.use_configured_addons && has_configured_source, + "fallbackToStremioMetaDetail": true + })) + .ok() +} + +pub(crate) fn repository_season_videos_json(meta_detail_json: &str, season_number: i32) -> String { + let videos = serde_json::from_str::(meta_detail_json) + .ok() + .and_then(|value| value.get("videos").cloned()) + .and_then(|value| value.as_array().cloned()) + .unwrap_or_default() + .into_iter() + .filter(|video| { + video + .get("season") + .and_then(Value::as_i64) + .map(|season| season == season_number as i64) + .unwrap_or(false) + }) + .collect::>(); + Value::Array(videos).to_string() +} + +pub(crate) fn manifest_fetch_decision_json(request_json: &str) -> Option { + let request: ManifestFetchDecisionRequest = serde_json::from_str(request_json).ok()?; + let phase = if request.force_refresh { + "fetch" + } else if request.memory_hit { + "memory" + } else if request.persistent_hit { + "persistent" + } else { + "fetch" + }; + serde_json::to_string(&json!({ + "phase": phase, + "allowStaleFallback": true + })) + .ok() +} + +pub(crate) fn addon_resource_request_plan_json(request_json: &str) -> Option { + let request: AddonResourceRequestPlan = serde_json::from_str(request_json).ok()?; + let mut urls = Vec::new(); + if request.resource == "subtitles" || request.resource == "subtitle" { + urls.push(build_resource_url( + &request.transport_url, + "subtitles", + &request.content_type, + &request.id, + None, + )); + if !request.extra_raw.trim().is_empty() { + urls.push(build_resource_url( + &request.transport_url, + "subtitles", + &request.content_type, + &request.id, + parse_extra_args_json(&request.extra_raw).as_deref(), + )); + } + } else { + urls.push(build_resource_url( + &request.transport_url, + &request.resource, + &request.content_type, + &request.id, + Some(&Value::Object(request.extra_args).to_string()), + )); + } + urls.dedup(); + serde_json::to_string(&json!({ "urls": urls })).ok() +} + +pub(crate) fn addon_streams_with_provider_json(streams_json: &str, addon_name: &str) -> String { + let streams = serde_json::from_str::>(streams_json) + .unwrap_or_default() + .into_iter() + .map(|stream| normalize_stream(stream, addon_name)) + .collect::>(); + Value::Array(streams).to_string() +} + +fn normalize_stream(mut stream: Value, addon_name: &str) -> Value { + let Some(stream_object) = stream.as_object_mut() else { + return stream; + }; + if !addon_name.trim().is_empty() { + stream_object.insert( + "addonName".to_string(), + Value::String(addon_name.to_string()), + ); + } + let Some(behavior_hints) = stream_object + .get("behaviorHints") + .and_then(Value::as_object) + .cloned() + else { + return stream; + }; + + let mut headers = Map::new(); + collect_headers(behavior_hints.get("requestHeaders"), &mut headers); + let proxy_request = behavior_hints + .get("proxyHeaders") + .and_then(|proxy| proxy.get("request")); + collect_headers(proxy_request, &mut headers); + + let mut final_hints = behavior_hints; + if !headers.is_empty() { + final_hints.insert("requestHeaders".to_string(), Value::Object(headers)); + } + fill_from_hint(stream_object, &final_hints, "videoHash"); + fill_from_hint(stream_object, &final_hints, "videoSize"); + fill_from_hint(stream_object, &final_hints, "filename"); + stream_object.insert("behaviorHints".to_string(), Value::Object(final_hints)); + stream +} + +fn collect_headers(value: Option<&Value>, headers: &mut Map) { + let Some(map) = value.and_then(Value::as_object) else { + return; + }; + for (key, value) in map { + if key.is_empty() { + continue; + } + let text = value + .as_str() + .map(str::to_string) + .unwrap_or_else(|| value.to_string()); + if !text.is_empty() { + headers.insert(key.clone(), Value::String(text)); + } + } +} + +fn fill_from_hint(stream_object: &mut Map, hints: &Map, key: &str) { + if stream_object + .get(key) + .filter(|value| !value.is_null()) + .is_some() + { + return; + } + if let Some(value) = hints.get(key) { + stream_object.insert(key.to_string(), value.clone()); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn repository_meta_detail_plan_prefers_addons_only_when_configured_source_exists() { + let plan = repository_meta_detail_plan_json( + r#"{"useConfiguredAddons":true,"authKey":"","localAddons":[]}"#, + ) + .unwrap(); + assert!(plan.contains(r#""preferAddonMetaDetail":false"#)); + + let plan = repository_meta_detail_plan_json( + r#"{"useConfiguredAddons":true,"authKey":"","localAddons":["https://addon/manifest.json"]}"#, + ) + .unwrap(); + assert!(plan.contains(r#""preferAddonMetaDetail":true"#)); + } + + #[test] + fn repository_season_videos_filters_by_season_without_reordering() { + let videos = repository_season_videos_json( + r#"{"videos":[{"id":"s2e1","season":2},{"id":"s1e1","season":1},{"id":"s2e2","season":2}]}"#, + 2, + ); + assert_eq!( + videos, + r#"[{"id":"s2e1","season":2},{"id":"s2e2","season":2}]"# + ); + } + + #[test] + fn manifest_fetch_decision_uses_cache_before_network_unless_forced() { + assert!(manifest_fetch_decision_json( + r#"{"forceRefresh":false,"memoryHit":true,"persistentHit":true}"# + ) + .unwrap() + .contains(r#""phase":"memory""#)); + assert!(manifest_fetch_decision_json( + r#"{"forceRefresh":true,"memoryHit":true,"persistentHit":true}"# + ) + .unwrap() + .contains(r#""phase":"fetch""#)); + } + + #[test] + fn addon_resource_plan_builds_subtitle_and_catalog_urls() { + let subtitles = addon_resource_request_plan_json( + r#"{"transportUrl":"https://addon.example/manifest.json","resource":"subtitles","contentType":"movie","id":"tt1","extraRaw":"videoHash=abc&filename=File Name.mkv"}"#, + ) + .unwrap(); + assert!(subtitles.contains("https://addon.example/subtitles/movie/tt1.json")); + assert!(subtitles.contains("videoHash=abc")); + assert!(subtitles.contains("filename=File%20Name.mkv")); + + let catalog = addon_resource_request_plan_json( + r#"{"transportUrl":"https://addon.example/manifest.json","resource":"catalog","contentType":"movie","id":"top","extraArgs":{"skip":"100","search":"matrix"}}"#, + ) + .unwrap(); + assert!(catalog.contains("catalog/movie/top/")); + assert!(catalog.contains("skip=100")); + assert!(catalog.contains("search=matrix")); + } + + #[test] + fn stream_provider_normalization_merges_headers_and_hints_without_reordering() { + let streams = addon_streams_with_provider_json( + r#"[{"title":"A","behaviorHints":{"videoHash":"abc","videoSize":12,"proxyHeaders":{"request":{"X-Proxy":"1"}}}},{"title":"B"}]"#, + "Addon", + ); + let value: Value = serde_json::from_str(&streams).unwrap(); + assert_eq!(value[0]["title"].as_str(), Some("A")); + assert_eq!(value[0]["addonName"].as_str(), Some("Addon")); + assert_eq!(value[0]["videoHash"].as_str(), Some("abc")); + assert_eq!(value[0]["videoSize"].as_i64(), Some(12)); + assert_eq!( + value[0]["behaviorHints"]["requestHeaders"]["X-Proxy"].as_str(), + Some("1") + ); + assert_eq!(value[1]["title"].as_str(), Some("B")); + } +} diff --git a/src/runtime/effects.rs b/src/runtime/effects.rs new file mode 100644 index 0000000..b86fbc2 --- /dev/null +++ b/src/runtime/effects.rs @@ -0,0 +1,226 @@ +use serde::{Deserialize, Serialize}; + +/// Exhaustive catalog of all effect types the headless engine can emit. +/// +/// This is the single source of truth for effect type names — the string +/// representations produced by `as_str()` are the ones the platform (Kotlin, +/// JS, etc.) matches against in its effect dispatcher. +/// +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EffectKind { + ClearPlaybackProgress, + EnqueueOfflineDownload, + EnqueueTraktScrobble, + ExchangeAuthCode, + FetchAddonManifest, + FetchAddonResource, + FetchCatalogPage, + FetchDetailSecondary, + FetchDetailStreams, + FetchIntroSegments, + FetchMetaDetail, + FetchMetaDetailLookup, + FetchSeasonEpisodes, + FetchSubtitles, + NotifyReleasedEpisodes, + PrefetchDetailStreams, + PrefetchNextEpisodeStreams, + PrepareDirectPlayback, + ReadCalendarMonth, + ReadDetailLocalState, + ReadDiscoverCatalogFilters, + ReadHomeBootstrap, + ReadLibraryState, + ReadPlaybackProgress, + RefreshAuthToken, + RefreshInstalledAddons, + ReplaceExternalContinueWatching, + ResolveIntroImdbId, + RunAuthFlow, + RunDiscover, + RunExternalSync, + RunSearch, + StartTorrentStream, + StopTorrent, + SyncExternalIntegration, + SyncWatchedState, + UpdateCalendarWidget, + WriteFeedback, + WriteLibraryCommand, + WritePlaybackProgress, + WriteSettings, +} + +impl EffectKind { + pub fn as_str(self) -> &'static str { + match self { + EffectKind::ClearPlaybackProgress => "clearPlaybackProgress", + EffectKind::EnqueueOfflineDownload => "enqueueOfflineDownload", + EffectKind::EnqueueTraktScrobble => "enqueueTraktScrobble", + EffectKind::ExchangeAuthCode => "exchangeAuthCode", + EffectKind::FetchAddonManifest => "fetchAddonManifest", + EffectKind::FetchAddonResource => "fetchAddonResource", + EffectKind::FetchCatalogPage => "fetchCatalogPage", + EffectKind::FetchDetailSecondary => "fetchDetailSecondary", + EffectKind::FetchDetailStreams => "fetchDetailStreams", + EffectKind::FetchIntroSegments => "fetchIntroSegments", + EffectKind::FetchMetaDetail => "fetchMetaDetail", + EffectKind::FetchMetaDetailLookup => "fetchMetaDetailLookup", + EffectKind::FetchSeasonEpisodes => "fetchSeasonEpisodes", + EffectKind::FetchSubtitles => "fetchSubtitles", + EffectKind::NotifyReleasedEpisodes => "notifyReleasedEpisodes", + EffectKind::PrefetchDetailStreams => "prefetchDetailStreams", + EffectKind::PrefetchNextEpisodeStreams => "prefetchNextEpisodeStreams", + EffectKind::PrepareDirectPlayback => "prepareDirectPlayback", + EffectKind::ReadCalendarMonth => "readCalendarMonth", + EffectKind::ReadDetailLocalState => "readDetailLocalState", + EffectKind::ReadDiscoverCatalogFilters => "readDiscoverCatalogFilters", + EffectKind::ReadHomeBootstrap => "readHomeBootstrap", + EffectKind::ReadLibraryState => "readLibraryState", + EffectKind::ReadPlaybackProgress => "readPlaybackProgress", + EffectKind::RefreshAuthToken => "refreshAuthToken", + EffectKind::RefreshInstalledAddons => "refreshInstalledAddons", + EffectKind::ReplaceExternalContinueWatching => "replaceExternalContinueWatching", + EffectKind::ResolveIntroImdbId => "resolveIntroImdbId", + EffectKind::RunAuthFlow => "runAuthFlow", + EffectKind::RunDiscover => "runDiscover", + EffectKind::RunExternalSync => "runExternalSync", + EffectKind::RunSearch => "runSearch", + EffectKind::StartTorrentStream => "startTorrentStream", + EffectKind::StopTorrent => "stopTorrent", + EffectKind::SyncExternalIntegration => "syncExternalIntegration", + EffectKind::SyncWatchedState => "syncWatchedState", + EffectKind::UpdateCalendarWidget => "updateCalendarWidget", + EffectKind::WriteFeedback => "writeFeedback", + EffectKind::WriteLibraryCommand => "writeLibraryCommand", + EffectKind::WritePlaybackProgress => "writePlaybackProgress", + EffectKind::WriteSettings => "writeSettings", + } + } +} + +/// Wire format for an effect emitted by the headless engine. +/// +/// Matches the `NativeHeadlessEffect` data class on the Kotlin side: +/// ```kotlin +/// data class NativeHeadlessEffect( +/// val id: String, +/// val type: String, +/// val generation: Long, +/// val payload: Map +/// ) +/// ``` +/// +/// `id` is a monotonically-increasing opaque string (`"fx-N"`). +/// `generation` lets the platform discard stale completions. +/// `payload` carries effect-specific parameters as a JSON object. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EffectEnvelope { + pub id: String, + #[serde(rename = "type")] + pub kind: String, + pub generation: u64, + pub payload: serde_json::Value, +} + +impl EffectEnvelope { + pub fn new(id: String, kind: EffectKind, generation: u64, payload: serde_json::Value) -> Self { + Self { + id, + kind: kind.as_str().to_owned(), + generation, + payload, + } + } + + pub fn raw(id: String, kind: &str, generation: u64, payload: serde_json::Value) -> Self { + Self { + id, + kind: kind.to_owned(), + generation, + payload, + } + } +} + +/// Typed effect emitted by a portable engine model for the platform to execute. +/// +/// Mirrors stremio-core's `Effect` enum. Each variant carries fully-typed +/// payload fields, making it compile-time verified and WASM-safe. +/// +/// The headless engine currently serializes effects through `EffectEnvelope` +/// (untyped payload). This typed enum is the long-term migration target for +/// models that have been fully ported away from `serde_json::Value` state. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum Effect { + FetchAddonResource { + effect_id: String, + url: String, + timeout_ms: u32, + }, + FetchAddonManifest { + effect_id: String, + url: String, + timeout_ms: u32, + }, + FetchCatalogPage { + effect_id: String, + url: String, + }, + FetchMetaDetail { + effect_id: String, + url: String, + }, + FetchStreams { + effect_id: String, + url: String, + }, + FetchSubtitles { + effect_id: String, + url: String, + }, + GetStorage { + effect_id: String, + key: String, + }, + SetStorage { + effect_id: String, + key: String, + value: Option, + }, + Log { + message: String, + }, +} + +#[derive(Debug, Clone, Default)] +pub struct Effects { + pub effects: Vec, + pub has_changed: bool, +} + +impl Effects { + pub fn none() -> Self { + Self::default() + } + + pub fn changed() -> Self { + Self { + effects: vec![], + has_changed: true, + } + } + + pub fn with_effect(mut self, effect: Effect) -> Self { + self.effects.push(effect); + self + } + + pub fn merge(mut self, other: Effects) -> Self { + self.effects.extend(other.effects); + self.has_changed = self.has_changed || other.has_changed; + self + } +} diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs new file mode 100644 index 0000000..6db11b1 --- /dev/null +++ b/src/runtime/mod.rs @@ -0,0 +1,7 @@ +pub mod effects; +pub mod msg; +pub mod update; + +pub use effects::{Effect, EffectEnvelope, EffectKind, Effects}; +pub use msg::Msg; +pub use update::Update; diff --git a/src/runtime/msg/action.rs b/src/runtime/msg/action.rs new file mode 100644 index 0000000..d0aea94 --- /dev/null +++ b/src/runtime/msg/action.rs @@ -0,0 +1,47 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum Action { + LoadHome, + LoadCatalog { + addon_transport_url: String, + catalog_id: String, + content_type: String, + }, + LoadMetaDetail { + content_type: String, + id: String, + }, + LoadStreams { + content_type: String, + id: String, + }, + Search { + query: String, + }, + SetProfile { + profile_json: String, + }, + SyncLibrary, + PlayerStarted { + content_type: String, + id: String, + video_id: Option, + }, + PlayerStopped, + PlayerProgressUpdate { + position_ms: i64, + duration_ms: i64, + }, + InstallAddon { + transport_url: String, + }, + UninstallAddon { + transport_url: String, + }, + LibraryItemWatched { + id: String, + watched: bool, + }, +} diff --git a/src/runtime/msg/event.rs b/src/runtime/msg/event.rs new file mode 100644 index 0000000..606f8f3 --- /dev/null +++ b/src/runtime/msg/event.rs @@ -0,0 +1,25 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum Event { + ProfileChanged, + LibrarySynced, + AddonInstalled { + transport_url: String, + }, + AddonUninstalled { + transport_url: String, + }, + PlayerStopped { + content_type: Option, + id: Option, + video_id: Option, + duration_ms: i64, + time_offset_ms: i64, + }, + Error { + code: String, + message: String, + }, +} diff --git a/src/runtime/msg/internal.rs b/src/runtime/msg/internal.rs new file mode 100644 index 0000000..9397971 --- /dev/null +++ b/src/runtime/msg/internal.rs @@ -0,0 +1,31 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum Internal { + AddonResourceFetched { + effect_id: String, + url: String, + status_code: u16, + body: Option, + }, + AddonManifestFetched { + effect_id: String, + url: String, + status_code: u16, + body: Option, + }, + StorageRead { + effect_id: String, + key: String, + value: Option, + }, + StorageWritten { + effect_id: String, + key: String, + }, + HttpError { + effect_id: String, + message: String, + }, +} diff --git a/src/runtime/msg/mod.rs b/src/runtime/msg/mod.rs new file mode 100644 index 0000000..825b906 --- /dev/null +++ b/src/runtime/msg/mod.rs @@ -0,0 +1,17 @@ +pub mod action; +pub mod event; +pub mod internal; + +pub use action::Action; +pub use event::Event; +pub use internal::Internal; + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", content = "payload", rename_all = "camelCase")] +pub enum Msg { + Action(Action), + Internal(Internal), + Event(Event), +} diff --git a/src/runtime/update.rs b/src/runtime/update.rs new file mode 100644 index 0000000..d088162 --- /dev/null +++ b/src/runtime/update.rs @@ -0,0 +1,9 @@ +use crate::runtime::effects::Effects; +use crate::runtime::msg::Msg; + +/// Mirrors stremio-core's `Update` trait. The engine calls `update` when a +/// new `Msg` arrives; the model mutates its own state and returns a set of +/// side-effect requests for the platform to execute. +pub trait Update { + fn update(&mut self, msg: &Msg) -> Effects; +} diff --git a/src/search_plan.rs b/src/search_plan.rs new file mode 100644 index 0000000..b29b2e3 --- /dev/null +++ b/src/search_plan.rs @@ -0,0 +1,633 @@ +use serde::Deserialize; +use crate::{addon_protocol, content_identity}; +use serde_json::{json, Value}; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SearchGroupingRequest { + #[serde(default)] + results: Vec, + #[serde(default)] + query: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct DiscoverSortRequest { + #[serde(default)] + items: Vec, + #[serde(default)] + sort_by: Option, + #[serde(default)] + ascending: bool, + #[serde(default)] + content_type_filter: Option, + #[serde(default)] + genre_filter: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct LibrarySortRequest { + #[serde(default)] + items: Vec, + #[serde(default)] + sort_by: Option, + #[serde(default)] + ascending: bool, + #[serde(default)] + type_filter: Option, + #[serde(default)] + status_filter: Option, +} + +fn manifest_value(addon: &Value) -> Option<&Value> { + addon.get("manifest").or_else(|| Some(addon)) +} + +fn addon_transport_url(addon: &Value) -> &str { + addon.get("transportUrl").and_then(Value::as_str).unwrap_or("") +} + +fn addon_manifest_name(addon: &Value) -> String { + let manifest = manifest_value(addon).unwrap_or(addon); + manifest + .get("name") + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .or_else(|| manifest.get("id").and_then(Value::as_str)) + .unwrap_or("Metadata") + .to_string() +} + +fn title_label(value: &str) -> String { + let label = value + .split(['_', '-', ' ']) + .filter(|part| !part.is_empty()) + .map(|part| { + let mut chars = part.chars(); + match chars.next() { + Some(first) => format!("{}{}", first.to_uppercase(), chars.as_str()), + None => String::new(), + } + }) + .collect::>() + .join(" "); + if label.is_empty() { + value.to_string() + } else { + label + } +} + +fn metadata_feed_home_title(label: &str) -> String { + let parts = label + .split(" - ") + .map(str::trim) + .filter(|part| !part.is_empty()) + .collect::>(); + match parts.len() { + 0 => label.to_string(), + 1 => parts[0].to_string(), + 2 => parts[1].to_string(), + _ => parts[1..].join(" "), + } +} + +fn discover_catalog_label(raw_name: Option<&str>, id: &str) -> String { + let fallback = title_label(id); + let base = raw_name + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(&fallback); + let mut label = base + .split(['-', ':', '|', '/']) + .map(str::trim) + .filter(|part| !part.is_empty()) + .collect::>() + .join(" "); + for word in ["cinemeta", "movie", "movies", "film", "films", "series", "shows", "tv"] { + label = label + .split_whitespace() + .filter(|part| !part.eq_ignore_ascii_case(word)) + .collect::>() + .join(" "); + } + if label.trim().is_empty() { + fallback + } else { + label + } +} + +fn catalog_extra_options(catalog: &Value, extra_name: &str) -> Vec { + catalog + .get("extra") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter(|extra| { + extra + .get("name") + .and_then(Value::as_str) + .is_some_and(|name| name.eq_ignore_ascii_case(extra_name)) + }) + .flat_map(|extra| { + extra + .get("options") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default() + }) + .filter_map(|value| value.as_str().map(str::trim).map(str::to_string)) + .filter(|value| !value.is_empty()) + .collect() +} + +fn catalog_genres(catalog: &Value) -> Vec { + let mut genres = catalog + .get("genres") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|value| value.as_str().map(str::trim).map(str::to_string)) + .filter(|value| !value.is_empty()) + .collect::>(); + for option in catalog_extra_options(catalog, "genre") { + if !genres.contains(&option) { + genres.push(option); + } + } + genres +} + +fn manifest_supports_catalog(manifest: &Value) -> bool { + serde_json::to_string(manifest) + .ok() + .is_some_and(|json| addon_protocol::supports_resource(&json, "catalog", None, None)) +} + +fn catalog_has_required_extra_except(catalog: &Value, allowed: &[&str]) -> bool { + let allowed_json = serde_json::to_string(&allowed.iter().map(|s| s.to_string()).collect::>()) + .unwrap_or_else(|_| "[]".to_string()); + serde_json::to_string(catalog) + .ok() + .is_some_and(|json| addon_protocol::catalog_has_required_extra_except(&json, &allowed_json)) +} + +fn catalog_requires_extra(catalog: &Value, extra_name: &str) -> bool { + serde_json::to_string(catalog) + .ok() + .is_some_and(|json| addon_protocol::catalog_requires_extra(&json, extra_name)) +} + +pub(crate) fn build_metadata_feed_options_json(addons_json: &str) -> Option { + let addons = serde_json::from_str::>(addons_json).ok()?; + let mut feeds = Vec::new(); + for addon in addons { + let Some(manifest) = manifest_value(&addon) else { + continue; + }; + if !manifest_supports_catalog(manifest) { + continue; + } + let addon_name = addon_manifest_name(&addon); + let transport_url = addon_transport_url(&addon); + let source_key = manifest + .get("id") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .unwrap_or(transport_url); + for catalog in manifest + .get("catalogs") + .and_then(Value::as_array) + .into_iter() + .flatten() + { + let Some(type_value) = catalog + .get("type") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + continue; + }; + let Some(id) = catalog + .get("id") + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + else { + continue; + }; + if catalog_has_required_extra_except(catalog, &[]) { + continue; + } + let name = catalog + .get("name") + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .map(str::to_string) + .unwrap_or_else(|| title_label(id)); + let key = format!( + "addon:{}:{}:{}", + content_identity::stable_feed_part(source_key), + content_identity::stable_feed_part(type_value), + content_identity::stable_feed_part(id) + ); + let label = format!("{addon_name} - {name}"); + feeds.push(json!({ + "key": key, + "label": label, + "homeTitle": metadata_feed_home_title(&label), + "transportUrl": transport_url, + "type": type_value, + "id": id, + "genre": Value::Null + })); + } + } + serde_json::to_string(&feeds).ok() +} + +pub(crate) fn discover_catalog_options_json(addons_json: &str, selected_type: &str) -> Option { + let addons = serde_json::from_str::>(addons_json).ok()?; + let normalized_type = selected_type.to_lowercase(); + let mut options = Vec::new(); + for addon in addons { + let Some(manifest) = manifest_value(&addon) else { + continue; + }; + if !manifest_supports_catalog(manifest) { + continue; + } + let transport_url = addon_transport_url(&addon); + let source_key = manifest + .get("id") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .unwrap_or(transport_url); + for catalog in manifest + .get("catalogs") + .and_then(Value::as_array) + .into_iter() + .flatten() + { + let Some(type_value) = catalog + .get("type") + .and_then(Value::as_str) + .and_then(content_identity::normalize_content_type) + else { + continue; + }; + if normalized_type != "all" && normalized_type != type_value { + continue; + } + let Some(id) = catalog + .get("id") + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + else { + continue; + }; + if catalog_has_required_extra_except(catalog, &["genre"]) { + continue; + } + let label = discover_catalog_label(catalog.get("name").and_then(Value::as_str), id); + let genres = catalog_genres(catalog); + options.push(json!({ + "key": format!( + "discover:{}:{}:{}", + content_identity::stable_feed_part(source_key), + content_identity::stable_feed_part(type_value), + content_identity::stable_feed_part(&label) + ), + "label": label, + "transportUrl": transport_url, + "type": type_value, + "id": id, + "genres": genres, + "requiresGenre": catalog_requires_extra(catalog, "genre") + })); + } + } + serde_json::to_string(&options).ok() +} + +pub(crate) fn search_result_grouping_json(request_json: &str) -> Option { + let request = serde_json::from_str::(request_json).ok()?; + let mut movies: Vec<&Value> = Vec::new(); + let mut series: Vec<&Value> = Vec::new(); + let mut other: Vec<&Value> = Vec::new(); + for item in &request.results { + match item.get("type").and_then(Value::as_str).unwrap_or("") { + "movie" => movies.push(item), + "series" | "anime" => series.push(item), + _ => other.push(item), + } + } + let mut groups = Vec::new(); + if !movies.is_empty() { + groups.push(json!({ "type": "movie", "items": movies })); + } + if !series.is_empty() { + groups.push(json!({ "type": "series", "items": series })); + } + if !other.is_empty() { + groups.push(json!({ "type": "other", "items": other })); + } + serde_json::to_string(&json!({ + "groups": groups, + "totalCount": request.results.len(), + "query": request.query + })) + .ok() +} + +pub(crate) fn discover_sort_plan_json(request_json: &str) -> Option { + let request = serde_json::from_str::(request_json).ok()?; + let content_type = request.content_type_filter.as_deref().unwrap_or(""); + let genre = request.genre_filter.as_deref().unwrap_or("").to_lowercase(); + let sort_by = request.sort_by.as_deref().unwrap_or("default"); + + let mut filtered: Vec<&Value> = request + .items + .iter() + .filter(|item| { + let type_ok = content_type.is_empty() + || item + .get("type") + .and_then(Value::as_str) + .is_some_and(|t| t == content_type); + let genre_ok = genre.is_empty() + || item + .get("genres") + .and_then(Value::as_array) + .is_some_and(|g| { + g.iter() + .any(|gv| gv.as_str().is_some_and(|s| s.to_lowercase() == genre)) + }); + type_ok && genre_ok + }) + .collect(); + + match sort_by { + "year" => { + filtered.sort_by(|a, b| { + let ya = a.get("releaseInfo").and_then(Value::as_str).and_then(|s| s.parse::().ok()).unwrap_or(0); + let yb = b.get("releaseInfo").and_then(Value::as_str).and_then(|s| s.parse::().ok()).unwrap_or(0); + if request.ascending { ya.cmp(&yb) } else { yb.cmp(&ya) } + }); + } + "rating" => { + filtered.sort_by(|a, b| { + let ra = a.get("imdbRating").and_then(Value::as_f64).unwrap_or(0.0); + let rb = b.get("imdbRating").and_then(Value::as_f64).unwrap_or(0.0); + if request.ascending { + ra.partial_cmp(&rb).unwrap_or(std::cmp::Ordering::Equal) + } else { + rb.partial_cmp(&ra).unwrap_or(std::cmp::Ordering::Equal) + } + }); + } + "name" => { + filtered.sort_by(|a, b| { + let na = a.get("name").and_then(Value::as_str).unwrap_or(""); + let nb = b.get("name").and_then(Value::as_str).unwrap_or(""); + if request.ascending { na.cmp(nb) } else { nb.cmp(na) } + }); + } + _ => {} + } + + serde_json::to_string(&json!({ + "items": filtered, + "sortBy": sort_by, + "ascending": request.ascending, + "totalCount": filtered.len() + })) + .ok() +} + +pub(crate) fn library_sort_plan_json(request_json: &str) -> Option { + let request = serde_json::from_str::(request_json).ok()?; + let type_filter = request.type_filter.as_deref().unwrap_or("").to_lowercase(); + let status_filter = request.status_filter.as_deref().unwrap_or("").to_lowercase(); + let sort_by = request.sort_by.as_deref().unwrap_or("added"); + + let mut filtered: Vec<&Value> = request + .items + .iter() + .filter(|item| { + let type_ok = type_filter.is_empty() + || item + .get("type") + .and_then(Value::as_str) + .is_some_and(|t| t.to_lowercase() == type_filter); + let status_ok = status_filter.is_empty() + || item + .get("status") + .and_then(Value::as_str) + .is_some_and(|s| s.to_lowercase() == status_filter); + type_ok && status_ok + }) + .collect(); + + match sort_by { + "name" => { + filtered.sort_by(|a, b| { + let na = a.get("name").and_then(Value::as_str).unwrap_or(""); + let nb = b.get("name").and_then(Value::as_str).unwrap_or(""); + if request.ascending { na.cmp(nb) } else { nb.cmp(na) } + }); + } + "year" => { + filtered.sort_by(|a, b| { + let ya = a.get("releaseInfo").and_then(Value::as_str).and_then(|s| s.parse::().ok()).unwrap_or(0); + let yb = b.get("releaseInfo").and_then(Value::as_str).and_then(|s| s.parse::().ok()).unwrap_or(0); + if request.ascending { ya.cmp(&yb) } else { yb.cmp(&ya) } + }); + } + "progress" => { + filtered.sort_by(|a, b| { + let pa = a.get("timeOffset").and_then(Value::as_i64).unwrap_or(0); + let pb = b.get("timeOffset").and_then(Value::as_i64).unwrap_or(0); + if request.ascending { pa.cmp(&pb) } else { pb.cmp(&pa) } + }); + } + _ => {} + } + + serde_json::to_string(&json!({ + "items": filtered, + "sortBy": sort_by, + "totalCount": filtered.len() + })) + .ok() +} + +pub(crate) fn detail_series_lookup_id(raw_id: &str) -> String { + let trimmed = raw_id.trim(); + if trimmed.is_empty() { + return String::new(); + } + if let Some(imdb) = extract_imdb_id(trimmed) { + return imdb; + } + // Strip trailing season:episode parts (e.g. "kitsu:777:1:2" -> "kitsu:777", "base:1:2" -> "base") + let parts: Vec<&str> = trimmed.split(':').collect(); + if parts.len() >= 3 { + let last = parts[parts.len() - 1]; + let second_last = parts[parts.len() - 2]; + if last.parse::().is_ok() && second_last.parse::().is_ok() { + return parts[..parts.len() - 2].join(":"); + } + } + trimmed.to_string() +} + +fn extract_imdb_id(raw: &str) -> Option { + let mut start = 0; + let bytes = raw.as_bytes(); + while start < bytes.len() { + if bytes[start] == b't' && start + 2 < bytes.len() && bytes[start + 1] == b't' { + let end = bytes[start..] + .iter() + .take_while(|&&b| b.is_ascii_digit() || (b == b't' && start == 0)) + .count(); + let candidate = &raw[start..start + end]; + if candidate.starts_with("tt") && candidate[2..].chars().all(|c| c.is_ascii_digit()) && candidate.len() > 3 { + return Some(candidate.to_string()); + } + } + start += 1; + } + None +} + +pub(crate) fn detail_season_load_plan_json(request_json: &str) -> Option { + let value: Value = serde_json::from_str(request_json).ok()?; + let saved_video_id = value + .get("savedVideoId") + .and_then(Value::as_str) + .unwrap_or(""); + let seasons_count = value + .get("seasonsCount") + .and_then(Value::as_i64) + .unwrap_or(1) + .max(1) as i32; + + let saved_season = saved_video_id + .split(':') + .nth(1) + .and_then(|s| s.parse::().ok()) + .unwrap_or(0); + + let first_season = if saved_season > 0 && saved_season <= seasons_count { + saved_season + } else { + 1 + }; + + serde_json::to_string(&json!({ + "firstSeasonToLoad": first_season, + "savedSeason": if saved_season > 0 { json!(saved_season) } else { Value::Null } + })) + .ok() +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::Value; + + #[test] + fn search_grouping_separates_movies_series_and_other() { + let result: Value = serde_json::from_str( + &search_result_grouping_json( + r#"{"query":"breaking","results":[ + {"id":"tt1","type":"series","name":"Breaking Bad"}, + {"id":"tt2","type":"movie","name":"Breaking"}, + {"id":"tt3","type":"other","name":"Another"} + ]}"#, + ) + .unwrap(), + ) + .unwrap(); + let groups = result["groups"].as_array().unwrap(); + assert_eq!(groups[0]["type"], "movie"); + assert_eq!(groups[1]["type"], "series"); + assert_eq!(groups[2]["type"], "other"); + } + + #[test] + fn discover_sort_filters_by_content_type() { + let result: Value = serde_json::from_str( + &discover_sort_plan_json( + r#"{"contentTypeFilter":"movie","items":[{"id":"tt1","type":"movie"},{"id":"tt2","type":"series"}]}"#, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(result["items"].as_array().unwrap().len(), 1); + } + + #[test] + fn library_sort_filters_by_type() { + let result: Value = serde_json::from_str( + &library_sort_plan_json( + r#"{"typeFilter":"movie","items":[{"id":"tt1","type":"movie"},{"id":"tt2","type":"series"}]}"#, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(result["items"].as_array().unwrap().len(), 1); + } + + #[test] + fn metadata_feed_options_preserve_custom_stremio_catalog_types() { + let result: Value = serde_json::from_str( + &build_metadata_feed_options_json( + r#"[{"transportUrl":"https://aio.example/stremio/u/manifest.json","manifest":{"id":"aiometadata","name":"AIOMetadata","resources":["catalog"],"catalogs":[{"type":"anime.movie","id":"mal.top","name":"MAL Top"},{"type":"Trakt","id":"trakt.upnext","name":"Up Next"}]}}]"#, + ) + .unwrap(), + ) + .unwrap(); + let feeds = result.as_array().unwrap(); + assert_eq!(feeds.len(), 2); + assert_eq!(feeds[0]["type"], "anime.movie"); + assert_eq!(feeds[1]["type"], "Trakt"); + } + + #[test] + fn series_lookup_id_extracts_imdb_id() { + assert_eq!(detail_series_lookup_id("tt1234567:1:2"), "tt1234567"); + assert_eq!(detail_series_lookup_id("tt9999999"), "tt9999999"); + } + + #[test] + fn series_lookup_id_strips_episode_parts_for_non_imdb() { + assert_eq!(detail_series_lookup_id("kitsu:777:1:2"), "kitsu:777"); + assert_eq!(detail_series_lookup_id("tmdb:12345:1:2"), "tmdb:12345"); + } + + #[test] + fn season_load_plan_uses_saved_season_when_valid() { + let result: Value = serde_json::from_str( + &detail_season_load_plan_json( + r#"{"savedVideoId":"tt1:3:2","seasonsCount":5}"#, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(result["firstSeasonToLoad"], 3); + } + + #[test] + fn season_load_plan_defaults_to_season_1_when_no_saved() { + let result: Value = serde_json::from_str( + &detail_season_load_plan_json(r#"{"seasonsCount":5}"#).unwrap(), + ) + .unwrap(); + assert_eq!(result["firstSeasonToLoad"], 1); + } +} diff --git a/src/stream_policy.rs b/src/stream_policy.rs new file mode 100644 index 0000000..c6043cf --- /dev/null +++ b/src/stream_policy.rs @@ -0,0 +1,949 @@ +use crate::content_identity::stream_matches_episode; +use serde_json::{json, Value}; +use std::collections::HashMap; + +const STREAM_SOURCE_MODE_FIRST: &str = "first"; +const STREAM_SOURCE_MODE_REGEX: &str = "regex"; + +pub(crate) fn stream_behavior_text<'a>(stream: &'a Value, key: &str) -> Option<&'a str> { + stream + .get("behaviorHints") + .and_then(|hints| hints.get(key)) + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) +} + +pub(crate) fn stream_text<'a>(stream: &'a Value, key: &str) -> Option<&'a str> { + stream + .get(key) + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) +} + +pub(crate) fn stream_number(stream: &Value, key: &str) -> Option { + stream.get(key).and_then(Value::as_i64).or_else(|| { + stream + .get("behaviorHints") + .and_then(|hints| hints.get(key)) + .and_then(Value::as_i64) + }) +} + +pub(crate) fn stream_playable_url(stream: &Value) -> Option { + if let Some(url) = stream_text(stream, "url") { + return Some(url.to_string()); + } + if let Some(yt_id) = stream_text(stream, "ytId") { + return Some(format!("https://www.youtube.com/watch?v={yt_id}")); + } + if let Some(yt_id) = stream_text(stream, "yt_ID") { + return Some(format!("https://www.youtube.com/watch?v={yt_id}")); + } + if let Some(external_url) = stream_text(stream, "externalUrl") { + return Some(external_url.to_string()); + } + let info_hash = stream_text(stream, "infoHash")?; + match stream.get("fileIdx").and_then(Value::as_i64) { + Some(file_idx) => Some(format!("stremio://torrent/{info_hash}/{file_idx}")), + None => Some(format!("stremio://torrent/{info_hash}")), + } +} + +pub(crate) fn percent_decode_component(value: &str) -> String { + let mut bytes = Vec::with_capacity(value.len()); + let raw = value.as_bytes(); + let mut index = 0; + while index < raw.len() { + if raw[index] == b'%' && index + 2 < raw.len() { + if let Ok(hex) = u8::from_str_radix(&value[index + 1..index + 3], 16) { + bytes.push(hex); + index += 3; + continue; + } + } + bytes.push(raw[index]); + index += 1; + } + String::from_utf8_lossy(&bytes).into_owned() +} + +pub(crate) fn stream_effective_filename( + stream: &Value, + playable_url: Option<&str>, +) -> Option { + if let Some(filename) = stream_text(stream, "filename") { + return Some(filename.to_string()); + } + if let Some(filename) = stream_behavior_text(stream, "filename") { + return Some(filename.to_string()); + } + let url = stream_text(stream, "url").or(playable_url)?; + let path = url + .split('?') + .next() + .unwrap_or(url) + .trim_end_matches('/') + .rsplit('/') + .next() + .unwrap_or(""); + if path.is_empty() { + None + } else { + Some(percent_decode_component(path)) + } +} + +pub(crate) fn form_encode(value: &str) -> String { + let mut encoded = String::with_capacity(value.len()); + for byte in value.bytes() { + if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'*') { + encoded.push(byte as char); + } else if byte == b' ' { + encoded.push('+'); + } else { + encoded.push_str(&format!("%{byte:02X}")); + } + } + encoded +} + +pub(crate) fn is_torrent_playback_url(value: &str) -> bool { + value.starts_with("stremio://torrent/") + || value.starts_with("magnet:") + || value.starts_with("infohash:") +} + +pub(crate) fn stream_is_likely_player_compatible( + _stream: &Value, + playable_url: Option<&str>, + _effective_filename: Option<&str>, +) -> bool { + let Some(candidate) = playable_url + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + return false; + }; + let normalized = candidate.to_ascii_lowercase(); + if is_torrent_playback_url(&normalized) { + return true; + } + if !normalized.starts_with("http://") && !normalized.starts_with("https://") { + return false; + } + true +} + +pub(crate) fn stream_playback_info_json(stream_json: &str) -> Option { + let stream = serde_json::from_str::(stream_json).ok()?; + let playable_url = stream_playable_url(&stream); + let effective_video_hash = stream_text(&stream, "videoHash") + .or_else(|| stream_behavior_text(&stream, "videoHash")) + .map(str::to_string); + let effective_video_size = + stream_number(&stream, "videoSize").or_else(|| stream_number(&stream, "size")); + let effective_filename = stream_effective_filename(&stream, playable_url.as_deref()); + let subtitle_parts = [ + effective_video_hash + .as_ref() + .map(|value| ("videoHash", value.clone())), + effective_video_size.map(|value| ("videoSize", value.to_string())), + effective_filename + .as_ref() + .map(|value| ("filename", value.clone())), + ] + .into_iter() + .flatten() + .map(|(key, value)| format!("{}={}", form_encode(key), form_encode(&value))) + .collect::>(); + let is_torrent = playable_url + .as_deref() + .map(is_torrent_playback_url) + .unwrap_or(false); + let is_compatible = stream_is_likely_player_compatible( + &stream, + playable_url.as_deref(), + effective_filename.as_deref(), + ); + serde_json::to_string(&json!({ + "playableUrl": playable_url, + "effectiveVideoHash": effective_video_hash, + "effectiveVideoSize": effective_video_size, + "effectiveFilename": effective_filename, + "subtitleExtraArgs": subtitle_parts.join("&"), + "isTorrentPlaybackUrl": is_torrent, + "isLikelyPlayerCompatible": is_compatible + })) + .ok() +} + +pub(crate) fn stream_request_headers_json(headers_json: &str) -> Option { + let headers = serde_json::from_str::>(headers_json).ok()?; + let clean = headers + .into_iter() + .filter(|(key, value)| !key.trim().is_empty() && !value.trim().is_empty()) + .collect::>(); + serde_json::to_string(&clean).ok() +} + +pub(crate) fn stream_request_referer(_url: &str) -> Option { + None +} + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct TorrentRuntimeRequest { + link: String, + title: String, + requested_file_idx: Option, + preferred_filename: Option, + sources: Vec, + file_stats: Vec, + rejected_index: Option, + base_url: String, + play: bool, + stat: bool, +} + +#[derive(Clone, serde::Deserialize)] +pub(crate) struct TorrentFileStat { + id: i32, + path: String, + length: i64, +} + +pub(crate) fn is_bare_info_hash(value: &str) -> bool { + let length = value.len(); + matches!(length, 32 | 40 | 64) && value.chars().all(|ch| ch.is_ascii_hexdigit()) +} + +pub(crate) fn normalize_torrent_link(link: &str, sources: &[String]) -> String { + let trimmed = link.trim(); + let lower = trimmed.to_ascii_lowercase(); + if lower.starts_with("stremio://torrent/") { + let rest = &trimmed["stremio://torrent/".len()..]; + let hash = rest.split('/').next().unwrap_or("").trim(); + if hash.is_empty() { + return trimmed.to_string(); + } + return build_magnet(hash, sources); + } + if lower.starts_with("infohash:") { + return build_magnet( + trimmed + .split_once(':') + .map(|(_, value)| value) + .unwrap_or(""), + sources, + ); + } + if is_bare_info_hash(trimmed) { + return build_magnet(trimmed, sources); + } + trimmed.to_string() +} + +// Popular fallback trackers always added to magnets so a bare info_hash (no +// addon-provided sources) doesn't have to round-trip DHT for peer discovery. +// Kept short — duplicates from `sources` are filtered out below. +const FALLBACK_TRACKERS: &[&str] = &[ + "udp://tracker.opentrackr.org:1337/announce", + "udp://open.stealth.si:80/announce", + "udp://tracker.torrent.eu.org:451/announce", + "udp://exodus.desync.com:6969/announce", + "udp://open.demonii.com:1337/announce", +]; + +pub(crate) fn build_magnet(hash: &str, sources: &[String]) -> String { + let mut trackers = Vec::new(); + for source in sources { + let tracker = source.strip_prefix("tracker:").unwrap_or(source).trim(); + if (tracker.starts_with("udp://") + || tracker.starts_with("http://") + || tracker.starts_with("https://")) + && !trackers.contains(&tracker.to_string()) + { + trackers.push(tracker.to_string()); + } + } + for fallback in FALLBACK_TRACKERS { + let tracker = fallback.to_string(); + if !trackers.contains(&tracker) { + trackers.push(tracker); + } + } + let tracker_query = trackers + .iter() + .map(|tracker| format!("&tr={}", form_encode(tracker))) + .collect::(); + format!( + "magnet:?xt=urn:btih:{}{}", + hash.to_ascii_lowercase(), + tracker_query + ) +} + +pub(crate) fn normalize_torrent_file_name(value: &str) -> String { + value + .to_ascii_lowercase() + .replace('\\', "/") + .split_whitespace() + .collect::>() + .join(" ") + .trim() + .to_string() +} + +pub(crate) fn is_likely_video_file(path: &str) -> bool { + let path = path.to_ascii_lowercase(); + [".mkv", ".mp4", ".avi", ".webm", ".m4v", ".mov", ".ts"] + .iter() + .any(|extension| path.ends_with(extension)) +} + +pub(crate) fn resolve_torrent_file_index( + _title: &str, + requested_file_idx: Option, + preferred_filename: Option<&str>, + file_stats: &[TorrentFileStat], +) -> (Option, Option) { + // addon-provided fileIdx is authoritative — use it directly + if let Some(idx) = requested_file_idx { + return (Some(idx), Some("requested".to_string())); + } + + if file_stats.is_empty() { + return (None, None); + } + + if let Some(preferred) = preferred_filename + .map(normalize_torrent_file_name) + .filter(|value| !value.is_empty()) + { + if let Some(stat) = file_stats.iter().find(|stat| { + let path = normalize_torrent_file_name(&stat.path); + path == preferred + || path.ends_with(&format!("/{preferred}")) + || path.rsplit('/').next() == Some(preferred.as_str()) + }) { + return (Some(stat.id), Some("filename".to_string())); + } + } + + file_stats + .iter() + .filter(|stat| is_likely_video_file(&stat.path)) + .max_by_key(|stat| stat.length) + .map(|stat| (Some(stat.id), Some("largest-video".to_string()))) + .unwrap_or((None, None)) +} + +pub(crate) fn torrent_fallback_file_indexes( + _title: &str, + rejected_index: Option, + file_stats: &[TorrentFileStat], +) -> Vec { + let mut videos: Vec<&TorrentFileStat> = file_stats + .iter() + .filter(|stat| is_likely_video_file(&stat.path)) + .collect(); + videos.sort_by_key(|stat| std::cmp::Reverse(stat.length)); + videos + .into_iter() + .filter(|stat| Some(stat.id) != rejected_index) + .map(|stat| stat.id) + .collect() +} + +pub(crate) fn query_encode(value: &str) -> String { + form_encode(value).replace('+', "%20") +} + +pub(crate) fn build_torrent_stream_url( + base_url: &str, + link: &str, + title: &str, + file_idx: Option, + play: bool, + stat: bool, +) -> String { + let base = format!("{}/stream/fname", base_url.trim_end_matches('/')); + let mut query = format!("link={}", query_encode(link)); + if let Some(index) = file_idx { + query.push_str(&format!("&index={index}")); + } + if play { + query.push_str("&play"); + } + if stat { + query.push_str("&stat"); + } + query.push_str(&format!("&title={}", query_encode(title))); + format!("{base}?{query}") +} + +pub(crate) fn torrent_runtime_info_json(request_json: &str) -> Option { + let request = serde_json::from_str::(request_json).ok()?; + let normalized_link = normalize_torrent_link(&request.link, &request.sources); + let (selected_file_idx, selected_reason) = resolve_torrent_file_index( + &request.title, + request.requested_file_idx, + request.preferred_filename.as_deref(), + &request.file_stats, + ); + let fallback_file_indexes = + torrent_fallback_file_indexes(&request.title, request.rejected_index, &request.file_stats); + let stream_url = build_torrent_stream_url( + &request.base_url, + &normalized_link, + &request.title, + selected_file_idx, + request.play, + request.stat, + ); + serde_json::to_string(&json!({ + "normalizedLink": normalized_link, + "selectedFileIdx": selected_file_idx, + "selectedReason": selected_reason, + "fallbackFileIndexes": fallback_file_indexes, + "streamUrl": stream_url + })) + .ok() +} + +pub(crate) fn torrent_buffer_progress(status: &Value) -> i32 { + let stat = status.get("stat").and_then(Value::as_i64).unwrap_or(0); + let preload = status.get("preload").and_then(Value::as_i64).unwrap_or(0); + let loaded_size = status + .get("loaded_size") + .or_else(|| status.get("loadedSize")) + .and_then(Value::as_i64) + .unwrap_or(0); + let preload_size = status + .get("preload_size") + .or_else(|| status.get("preloadSize")) + .and_then(Value::as_i64) + .unwrap_or(0); + let progress = status + .get("progress") + .and_then(Value::as_f64) + .unwrap_or(0.0); + let value = if stat >= 3 { + 100 + } else if preload > 0 { + preload as i32 + } else if preload_size > 0 { + ((loaded_size as f64 / preload_size as f64) * 100.0) as i32 + } else if loaded_size > 0 { + ((loaded_size as f64 / (512.0 * 1024.0)) * 100.0) as i32 + } else { + progress as i32 + }; + value.clamp(0, 100) +} + +pub(crate) fn torrent_is_playable_enough(status: &Value) -> bool { + let stat = status.get("stat").and_then(Value::as_i64).unwrap_or(0); + let preload = status.get("preload").and_then(Value::as_i64).unwrap_or(0); + let loaded_size = status + .get("loaded_size") + .or_else(|| status.get("loadedSize")) + .and_then(Value::as_i64) + .unwrap_or(0); + let preload_size = status + .get("preload_size") + .or_else(|| status.get("preloadSize")) + .and_then(Value::as_i64) + .unwrap_or(0); + stat >= 3 + || preload >= 100 + || (preload_size > 0 && loaded_size >= preload_size) + || (preload_size <= 0 && loaded_size >= 512 * 1024) +} + +pub(crate) fn torrent_status_key(status: &Value) -> &'static str { + match status.get("stat").and_then(Value::as_i64).unwrap_or(0) { + 1 => "player.torrent_status.preloading", + 2 => "player.torrent_status.downloading", + 3 => "player.torrent_status.ready", + _ => "player.torrent_status.loading_metadata", + } +} + +pub(crate) fn torrent_status_info_json(status_json: &str) -> Option { + let status = serde_json::from_str::(status_json).ok()?; + serde_json::to_string(&json!({ + "bufferProgress": torrent_buffer_progress(&status), + "isPlayableEnough": torrent_is_playable_enough(&status), + "statusKey": torrent_status_key(&status) + })) + .ok() +} + +pub(crate) fn normalize_language(value: &str) -> String { + value.to_lowercase() +} + +pub(crate) fn normalize_language_preference(value: &str) -> String { + normalize_language(value) + .split(['-', '_']) + .next() + .unwrap_or("") + .to_string() +} + +pub(crate) fn resolve_preferred_audio_language( + last_audio_language: Option<&str>, + preferred_audio_language: Option<&str>, + original_language: Option<&str>, +) -> String { + if let Some(memory) = last_audio_language + .map(normalize_language) + .filter(|value| !value.trim().is_empty()) + { + return memory; + } + let Some(preferred) = preferred_audio_language + .map(normalize_language) + .filter(|value| value != "none") + else { + return String::new(); + }; + if preferred != "en" { + return preferred; + } + if original_language.map(normalize_language).as_deref() == Some("ja") { + "ja".to_string() + } else { + preferred + } +} + +pub(crate) fn subtitle_language_alias_matches(label: &str, normalized_preference: &str) -> bool { + match normalized_preference { + "tr" => ["turkish", "turkce", "turk", "altyazi", "altyazı"] + .iter() + .any(|alias| label.contains(alias)), + "en" => ["english", "eng"].iter().any(|alias| label.contains(alias)), + "ja" => ["japanese", "jpn"] + .iter() + .any(|alias| label.contains(alias)), + _ => false, + } +} + +pub(crate) fn subtitle_language_matches( + label: &str, + language: Option<&str>, + preferred_language: &str, +) -> bool { + let normalized_preference = normalize_language_preference(preferred_language); + let word_regex = + regex::Regex::new(&format!(r"\b{}\b", regex::escape(&normalized_preference))).ok(); + subtitle_language_matches_precompiled(label, language, &normalized_preference, word_regex.as_ref()) +} + +fn subtitle_language_matches_precompiled( + label: &str, + language: Option<&str>, + normalized_preference: &str, + word_regex: Option<®ex::Regex>, +) -> bool { + if normalized_preference.is_empty() { + return false; + } + let language = normalize_language(language.unwrap_or("")); + if language.starts_with(normalized_preference) { + return true; + } + let label = normalize_language(label); + word_regex.is_some_and(|regex| regex.is_match(&label)) + || subtitle_language_alias_matches(&label, normalized_preference) +} + +#[derive(Clone, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct SubtitleSelectionTrack { + id: Option, + label: String, + language: Option, +} + +fn find_preferred_subtitle_index_in_tracks( + tracks: &[SubtitleSelectionTrack], + last_subtitle_language: Option<&str>, + preferred_subtitle_language: Option<&str>, + secondary_subtitle_language: Option<&str>, +) -> i32 { + let primary = last_subtitle_language + .filter(|value| !value.is_empty() && *value != "__off__") + .or_else(|| preferred_subtitle_language.filter(|value| *value != "none")); + if let Some(preferred) = primary { + let norm = normalize_language_preference(preferred); + let word_regex = + regex::Regex::new(&format!(r"\b{}\b", regex::escape(&norm))).ok(); + if let Some(index) = tracks.iter().position(|track| { + subtitle_language_matches_precompiled(&track.label, track.language.as_deref(), &norm, word_regex.as_ref()) + }) { + return index as i32; + } + } + if let Some(secondary) = secondary_subtitle_language.filter(|value| *value != "none") { + let norm = normalize_language_preference(secondary); + let word_regex = + regex::Regex::new(&format!(r"\b{}\b", regex::escape(&norm))).ok(); + if let Some(index) = tracks.iter().position(|track| { + subtitle_language_matches_precompiled(&track.label, track.language.as_deref(), &norm, word_regex.as_ref()) + }) { + return index as i32; + } + } + -1 +} + +pub(crate) fn find_preferred_subtitle_index( + tracks_json: &str, + last_subtitle_language: Option<&str>, + preferred_subtitle_language: Option<&str>, + secondary_subtitle_language: Option<&str>, +) -> i32 { + let Ok(tracks) = serde_json::from_str::>(tracks_json) else { + return -1; + }; + find_preferred_subtitle_index_in_tracks( + &tracks, + last_subtitle_language, + preferred_subtitle_language, + secondary_subtitle_language, + ) +} + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct PlayerTrackStateRequest { + #[serde(default)] + available_subtitles: Vec, + last_audio_language: Option, + preferred_audio_language: Option, + original_language: Option, + last_subtitle_language: Option, + preferred_subtitle_language: Option, + secondary_subtitle_language: Option, +} + +pub(crate) fn player_track_state_json(request_json: &str) -> Option { + let request = serde_json::from_str::(request_json).ok()?; + let preferred_audio_language = resolve_preferred_audio_language( + request + .last_audio_language + .as_deref() + .filter(|value| !value.is_empty()), + request + .preferred_audio_language + .as_deref() + .filter(|value| !value.is_empty()), + request + .original_language + .as_deref() + .filter(|value| !value.is_empty()), + ); + let preferred_subtitle_index = find_preferred_subtitle_index_in_tracks( + &request.available_subtitles, + request + .last_subtitle_language + .as_deref() + .filter(|value| !value.is_empty()), + request + .preferred_subtitle_language + .as_deref() + .filter(|value| !value.is_empty()), + request + .secondary_subtitle_language + .as_deref() + .filter(|value| !value.is_empty()), + ); + let preferred_subtitle_id = if preferred_subtitle_index >= 0 { + request + .available_subtitles + .get(preferred_subtitle_index as usize) + .and_then(|track| track.id.clone()) + } else { + None + }; + serde_json::to_string(&json!({ + "preferredAudioLanguage": preferred_audio_language, + "preferredSubtitleIndex": preferred_subtitle_index, + "preferredSubtitleId": preferred_subtitle_id, + "subtitlesDisabled": preferred_subtitle_index < 0 + })) + .ok() +} + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct StreamSelectionItem { + name: Option, + title: Option, + description: Option, + addon_name: Option, + playable_url: Option, + binge_group: Option, + filename: Option, + effective_filename: Option, +} + +impl StreamSelectionItem { + pub(crate) fn matches_episode(&self, video_id: &str) -> bool { + stream_matches_episode( + video_id, + &[ + self.title.clone().unwrap_or_default(), + self.name.clone().unwrap_or_default(), + self.description.clone().unwrap_or_default(), + self.filename.clone().unwrap_or_default(), + self.effective_filename.clone().unwrap_or_default(), + ], + ) + } + + pub(crate) fn is_playable_for_episode(&self, video_id: &str) -> bool { + self.playable_url + .as_deref() + .is_some_and(|value| !value.trim().is_empty()) + && self.matches_episode(video_id) + } + + pub(crate) fn selection_text(&self) -> String { + [ + self.name.as_deref(), + self.title.as_deref(), + self.description.as_deref(), + self.addon_name.as_deref(), + self.playable_url.as_deref(), + self.binge_group.as_deref(), + ] + .into_iter() + .flatten() + .collect::>() + .join(" ") + } +} + +fn stream_selection_item_from_value(v: &Value) -> StreamSelectionItem { + StreamSelectionItem { + name: v.get("name").and_then(Value::as_str).map(str::to_string), + title: v.get("title").and_then(Value::as_str).map(str::to_string), + description: v.get("description").and_then(Value::as_str).map(str::to_string), + addon_name: v.get("addonName").and_then(Value::as_str).map(str::to_string), + playable_url: v.get("playableUrl").and_then(Value::as_str).map(str::to_string), + binge_group: v.get("bingeGroup").and_then(Value::as_str).map(str::to_string), + filename: v.get("filename").and_then(Value::as_str).map(str::to_string), + effective_filename: v.get("effectiveFilename").and_then(Value::as_str).map(str::to_string), + } +} + +pub(crate) fn index_of_first_playable( + streams: &[StreamSelectionItem], + video_id: &str, + predicate: F, +) -> Option +where + F: Fn(&StreamSelectionItem) -> bool, +{ + streams + .iter() + .position(|stream| stream.is_playable_for_episode(video_id) && predicate(stream)) +} + +pub(crate) fn manual_stream_index( + streams: &[StreamSelectionItem], + video_id: &str, + initial_stream_index: i32, + saved_url: Option<&str>, + saved_title: Option<&str>, +) -> i32 { + let matched_index = saved_url + .filter(|value| !value.is_empty()) + .and_then(|value| { + index_of_first_playable(streams, video_id, |stream| { + stream.playable_url.as_deref() == Some(value) + }) + }) + .or_else(|| { + saved_title + .filter(|value| !value.is_empty()) + .and_then(|value| { + index_of_first_playable(streams, video_id, |stream| { + stream.title.as_deref() == Some(value) + }) + }) + }); + if let Some(index) = matched_index { + return index as i32; + } + + if initial_stream_index >= 0 + && streams + .get(initial_stream_index as usize) + .is_some_and(|stream| stream.matches_episode(video_id)) + { + return initial_stream_index; + } + + streams + .iter() + .position(|stream| stream.matches_episode(video_id)) + .map(|index| index as i32) + .unwrap_or(-1) +} + +fn select_stream_index_inner( + streams: &[StreamSelectionItem], + current_video_id: &str, + initial_stream_index: i32, + saved_url: Option<&str>, + saved_title: Option<&str>, + source_selection_mode: &str, + regex_pattern: Option<&str>, + preferred_binge_group: Option<&str>, +) -> i32 { + if streams.is_empty() { + return -1; + } + + if let Some(group) = preferred_binge_group.filter(|value| !value.trim().is_empty()) { + if let Some(index) = index_of_first_playable(streams, current_video_id, |stream| { + stream.binge_group.as_deref() == Some(group) + }) { + return index as i32; + } + } + + match source_selection_mode { + STREAM_SOURCE_MODE_REGEX => { + let Some(pattern) = regex_pattern.filter(|value| !value.trim().is_empty()) else { + return manual_stream_index(streams, current_video_id, initial_stream_index, saved_url, saved_title); + }; + let regex = match regex::RegexBuilder::new(pattern) + .case_insensitive(true) + .build() + { + Ok(regex) => regex, + Err(_) => return manual_stream_index(streams, current_video_id, initial_stream_index, saved_url, saved_title), + }; + if let Some(index) = index_of_first_playable(streams, current_video_id, |stream| { + regex.is_match(&stream.selection_text()) + }) { + return index as i32; + } + } + STREAM_SOURCE_MODE_FIRST => { + if let Some(index) = index_of_first_playable(streams, current_video_id, |_| true) { + return index as i32; + } + } + _ => {} + } + + manual_stream_index(streams, current_video_id, initial_stream_index, saved_url, saved_title) +} + +pub(crate) fn select_stream_index( + streams_json: &str, + current_video_id: &str, + initial_stream_index: i32, + saved_url: Option<&str>, + saved_title: Option<&str>, + source_selection_mode: &str, + regex_pattern: Option<&str>, + preferred_binge_group: Option<&str>, +) -> i32 { + let Ok(streams) = serde_json::from_str::>(streams_json) else { + return -1; + }; + select_stream_index_inner(&streams, current_video_id, initial_stream_index, saved_url, saved_title, source_selection_mode, regex_pattern, preferred_binge_group) +} + +pub(crate) fn select_stream_index_values( + streams: &[Value], + current_video_id: &str, + initial_stream_index: i32, + saved_url: Option<&str>, + saved_title: Option<&str>, + source_selection_mode: &str, + regex_pattern: Option<&str>, + preferred_binge_group: Option<&str>, +) -> i32 { + let items: Vec = streams.iter().map(stream_selection_item_from_value).collect(); + select_stream_index_inner(&items, current_video_id, initial_stream_index, saved_url, saved_title, source_selection_mode, regex_pattern, preferred_binge_group) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::Value; + + fn track_state(request: Value) -> Value { + serde_json::from_str(&player_track_state_json(&request.to_string()).unwrap()).unwrap() + } + + #[test] + fn player_track_state_uses_audio_memory_before_profile_preference() { + let state = track_state(json!({ + "lastAudioLanguage": "tr", + "preferredAudioLanguage": "en", + "originalLanguage": "en" + })); + + assert_eq!(state["preferredAudioLanguage"], "tr"); + } + + #[test] + fn player_track_state_uses_japanese_original_for_english_anime_preference() { + let state = track_state(json!({ + "preferredAudioLanguage": "en", + "originalLanguage": "ja" + })); + + assert_eq!(state["preferredAudioLanguage"], "ja"); + } + + #[test] + fn player_track_state_selects_subtitle_memory_then_secondary() { + let memory = track_state(json!({ + "availableSubtitles": [ + { "id": "en", "label": "English", "language": "en" }, + { "id": "tr", "label": "Turkish", "language": "tr" } + ], + "lastSubtitleLanguage": "tr", + "preferredSubtitleLanguage": "en" + })); + assert_eq!(memory["preferredSubtitleIndex"], 1); + assert_eq!(memory["preferredSubtitleId"], "tr"); + assert_eq!(memory["subtitlesDisabled"], false); + + let secondary = track_state(json!({ + "availableSubtitles": [ + { "id": "tr", "label": "Turkish", "language": "tr" } + ], + "preferredSubtitleLanguage": "en", + "secondarySubtitleLanguage": "tr" + })); + assert_eq!(secondary["preferredSubtitleIndex"], 0); + assert_eq!(secondary["preferredSubtitleId"], "tr"); + } + + #[test] + fn player_track_state_disables_subtitles_when_no_preferred_match_exists() { + let state = track_state(json!({ + "availableSubtitles": [ + { "id": "tr", "label": "Turkish", "language": "tr" } + ], + "preferredSubtitleLanguage": "en" + })); + + assert_eq!(state["preferredSubtitleIndex"], -1); + assert!(state["preferredSubtitleId"].is_null()); + assert_eq!(state["subtitlesDisabled"], true); + } +} diff --git a/src/tmdb_plan.rs b/src/tmdb_plan.rs new file mode 100644 index 0000000..3445cb4 --- /dev/null +++ b/src/tmdb_plan.rs @@ -0,0 +1,102 @@ +use serde_json::{json, Value}; + +pub(crate) fn tmdb_content_type(content_type: &str) -> &str { + if content_type == "series" { "tv" } else { "movie" } +} + +pub(crate) fn tmdb_language(language: &str) -> String { + match language { + "" | "en" => "en-US".to_string(), + "tr" => "tr-TR".to_string(), + lang if lang.contains('-') => lang.to_string(), + lang => format!("{}-{}", lang, lang.to_uppercase()), + } +} + +pub(crate) fn tmdb_image_url(path: Option<&str>, size: &str) -> Option { + let path = path?.trim(); + if path.is_empty() { return None; } + Some(format!("https://image.tmdb.org/t/p/{size}{path}")) +} + +pub(crate) fn tmdb_meta_to_meta_json(item_json: &str, requested_type: &str, language: &str) -> Option { + let item: Value = serde_json::from_str(item_json).ok()?; + let id = item.get("id").and_then(Value::as_i64)?; + let media_type = item.get("media_type").and_then(Value::as_str).unwrap_or(""); + let has_tv = media_type == "tv" || item.get("first_air_date").is_some(); + let content_type = if requested_type == "series" || has_tv { "series" } else { "movie" }; + let name = item.get("title") + .or_else(|| item.get("name")) + .or_else(|| item.get("original_name")) + .and_then(Value::as_str) + .unwrap_or(if language == "tr" { "Bilinmeyen" } else { "Unknown" }); + let released = item.get("release_date").or_else(|| item.get("first_air_date")) + .and_then(Value::as_str); + let poster = tmdb_image_url(item.get("poster_path").and_then(Value::as_str), "w500"); + let background = tmdb_image_url(item.get("backdrop_path").and_then(Value::as_str), "original"); + Some(serde_json::to_string(&json!({ + "id": format!("tmdb:{id}"), + "type": content_type, + "name": name, + "poster": poster, + "background": background, + "releaseInfo": released.map(|r| &r[..4.min(r.len())]), + })).ok()?) +} + +pub(crate) fn tmdb_video_to_trailer_json(video_json: &str) -> Option { + let video: Value = serde_json::from_str(video_json).ok()?; + let site = video.get("site").and_then(Value::as_str).unwrap_or("").to_lowercase(); + if site != "youtube" { return None; } + let key = video.get("key").and_then(Value::as_str).map(str::trim).filter(|s| !s.is_empty())?; + let video_type = video.get("type").and_then(Value::as_str).map(str::trim).unwrap_or("Trailer"); + let type_lower = video_type.to_lowercase(); + if !["trailer", "teaser", "clip"].contains(&type_lower.as_str()) { return None; } + let title = video.get("name").and_then(Value::as_str).map(str::trim) + .filter(|s| !s.is_empty()).unwrap_or(video_type); + Some(serde_json::to_string(&json!({ + "url": format!("https://www.youtube.com/watch?v={key}"), + "title": title, + "type": video_type, + })).ok()?) +} + +pub(crate) fn tmdb_bulk_metas_to_metas_json( + items_json: &str, + requested_type: &str, + language: &str, +) -> Option { + let items: Vec = serde_json::from_str(items_json).ok()?; + let metas: Vec = items.iter() + .filter_map(|item| { + let s = serde_json::to_string(item).ok()?; + let meta_json = tmdb_meta_to_meta_json(&s, requested_type, language)?; + serde_json::from_str(&meta_json).ok() + }) + .collect(); + serde_json::to_string(&metas).ok() +} + +pub(crate) fn tmdb_bulk_videos_to_trailers_json(items_json: &str) -> Option { + let items: Vec = serde_json::from_str(items_json).ok()?; + let trailers: Vec = items.iter() + .filter_map(|item| { + let s = serde_json::to_string(item).ok()?; + let json = tmdb_video_to_trailer_json(&s)?; + serde_json::from_str(&json).ok() + }) + .collect(); + serde_json::to_string(&trailers).ok() +} + +/// Returns (numeric_tmdb_id, already_resolved) — if already_resolved is true +/// the caller can use the id directly without an extra API call. +pub(crate) fn tmdb_resolve_id_hint(content_id: &str) -> (String, bool) { + let base = content_id.replace("tmdb:", ""); + let base = base.split(':').next().unwrap_or(&base); + if base.chars().all(|c| c.is_ascii_digit()) && !base.is_empty() { + return (base.to_string(), true); + } + let imdb_part = content_id.split(':').next().unwrap_or(content_id); + (imdb_part.to_string(), false) +} diff --git a/src/types/addon/mod.rs b/src/types/addon/mod.rs new file mode 100644 index 0000000..e3c5443 --- /dev/null +++ b/src/types/addon/mod.rs @@ -0,0 +1,87 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::HashMap; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AddonManifest { + pub id: String, + pub version: String, + pub name: String, + pub description: Option, + pub transport_url: String, + pub resources: Vec, + pub types: Vec, + pub id_prefixes: Option>, + pub catalogs: Vec, + pub addon_catalogs: Option>, + pub config: Option>, + pub background: Option, + pub logo: Option, + pub contact_email: Option, + pub behavior_hints: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[serde(untagged)] +pub enum ResourceDeclaration { + Name(String), + Object { + name: String, + types: Option>, + id_prefixes: Option>, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CatalogDeclaration { + pub id: String, + #[serde(rename = "type")] + pub type_: String, + pub name: Option, + pub extra: Option>, + pub extra_supported: Option>, + pub extra_required: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExtraDeclaration { + pub name: String, + pub is_required: Option, + pub options: Option>, + pub options_limit: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ConfigDeclaration { + pub key: String, + #[serde(rename = "type")] + pub type_: String, + pub default: Option, + pub title: Option, + pub options: Option>, + pub required: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BehaviorHints { + pub adult: Option, + pub p2p: Option, + pub configurable: Option, + pub configuration_required: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ResourceRef { + pub resource: String, + #[serde(rename = "type")] + pub type_: String, + pub id: String, + pub extra: Option>, +} diff --git a/src/types/mod.rs b/src/types/mod.rs new file mode 100644 index 0000000..12b42c2 --- /dev/null +++ b/src/types/mod.rs @@ -0,0 +1,5 @@ +pub mod addon; +pub mod resource; + +pub use addon::{AddonManifest, ResourceRef}; +pub use resource::{MetaItem, Stream}; diff --git a/src/types/resource/mod.rs b/src/types/resource/mod.rs new file mode 100644 index 0000000..e129fd4 --- /dev/null +++ b/src/types/resource/mod.rs @@ -0,0 +1,127 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MetaItem { + pub id: String, + #[serde(rename = "type")] + pub type_: String, + pub name: String, + pub poster: Option, + pub poster_shape: Option, + pub background: Option, + pub logo: Option, + pub description: Option, + pub release_info: Option, + pub imdb_rating: Option, + pub released: Option, + pub genres: Option>, + pub director: Option>, + pub cast: Option>, + pub year: Option, + pub runtime: Option, + pub language: Option, + pub country: Option, + pub awards: Option, + pub website: Option, + pub trailers: Option>, + pub videos: Option>, + pub links: Option>, + pub behavior_hints: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Video { + pub id: String, + pub title: Option, + pub released: Option, + pub season: Option, + pub episode: Option, + pub thumbnail: Option, + pub streams: Option>, + pub available: Option, + pub trailer: Option, + pub trailers: Option>, + pub overview: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Stream { + pub url: Option, + pub yt_id: Option, + pub info_hash: Option, + pub file_idx: Option, + pub file_must_include: Option, + pub nzb_url: Option, + pub servers: Option>, + pub rar_urls: Option>, + pub zip_urls: Option>, + #[serde(rename = "7zipUrls")] + pub seven_zip_urls: Option>, + pub tgz_urls: Option>, + pub tar_urls: Option>, + pub external_url: Option, + pub name: Option, + pub title: Option, + pub description: Option, + pub sources: Option>, + #[serde(rename = "subtitles")] + pub subtitles: Option>, + pub subtitle_tracks: Option>, + pub headers: Option>, + pub behavior_hints: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SourceObject { + pub url: String, + pub bytes: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SubtitleTrack { + pub id: String, + pub url: String, + pub lang: String, + pub label: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Link { + pub name: String, + pub category: String, + pub url: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MetaBehaviorHints { + pub default_video_id: Option, + pub featured_video_id: Option, + pub has_scheduled_videos: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StreamBehaviorHints { + pub country_whitelist: Option>, + pub not_web_ready: Option, + pub video_hash: Option, + pub video_size: Option, + pub filename: Option, + pub binge_group: Option, + pub proxy_headers: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProxyHeaders { + pub request: Option>, + pub response: Option>, +} diff --git a/src/watchlist_plan.rs b/src/watchlist_plan.rs new file mode 100644 index 0000000..5e85ba8 --- /dev/null +++ b/src/watchlist_plan.rs @@ -0,0 +1,277 @@ +use serde::Deserialize; +use serde_json::{json, Value}; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct TogglePlanRequest { + item: Value, + #[serde(default)] + is_currently_in_watchlist: bool, + #[serde(default)] + profile_id: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ExternalMergeRequest { + #[serde(default)] + local_items: Vec, + #[serde(default)] + external_items: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct CollectionImportRequest { + #[serde(default)] + collections: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct OfflineGroupingRequest { + #[serde(default)] + items: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ProgressMergeRequest { + existing: Value, + incoming: Value, +} + +pub(crate) fn watchlist_toggle_plan_json(request_json: &str) -> Option { + let request = serde_json::from_str::(request_json).ok()?; + let is_in_watchlist = request.is_currently_in_watchlist; + let should_add = !is_in_watchlist; + let item_id = request + .item + .get("id") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + serde_json::to_string(&json!({ + "command": if should_add { "add" } else { "remove" }, + "itemId": item_id, + "optimisticIsInWatchlist": should_add, + "profileId": request.profile_id + })) + .ok() +} + +pub(crate) fn library_external_merge_plan_json(request_json: &str) -> Option { + let request = serde_json::from_str::(request_json).ok()?; + let local_ids: std::collections::HashSet = request + .local_items + .iter() + .filter_map(|item| item.get("id").and_then(Value::as_str).map(ToString::to_string)) + .collect(); + let merged_external: Vec<&Value> = request + .external_items + .iter() + .filter(|item| { + item.get("id") + .and_then(Value::as_str) + .is_some_and(|id| !local_ids.contains(id)) + }) + .collect(); + let mut merged: Vec = request.local_items.clone(); + merged.extend(merged_external.into_iter().cloned()); + serde_json::to_string(&json!({ + "merged": merged, + "localCount": request.local_items.len(), + "externalOnlyCount": merged.len() - request.local_items.len() + })) + .ok() +} + +pub(crate) fn library_collection_import_validation_json(request_json: &str) -> Option { + let request = serde_json::from_str::(request_json).ok()?; + let mut issues = Vec::::new(); + let mut valid_collections = Vec::::new(); + for (i, col) in request.collections.iter().enumerate() { + let id = col.get("id").and_then(Value::as_str).unwrap_or("").trim(); + let title = col.get("title").and_then(Value::as_str).unwrap_or("").trim(); + if id.is_empty() { + issues.push(format!("collection[{}]: missing id", i)); + continue; + } + if title.is_empty() { + issues.push(format!("collection[{}]: missing title", i)); + continue; + } + valid_collections.push(col.clone()); + } + serde_json::to_string(&json!({ + "isValid": issues.is_empty(), + "validCollections": valid_collections, + "issues": issues + })) + .ok() +} + +pub(crate) fn library_offline_grouping_json(request_json: &str) -> Option { + let request = serde_json::from_str::(request_json).ok()?; + let mut ready = Vec::<&Value>::new(); + let mut downloading = Vec::<&Value>::new(); + let mut queued = Vec::<&Value>::new(); + let mut failed = Vec::<&Value>::new(); + for item in &request.items { + match item.get("status").and_then(Value::as_str).unwrap_or("") { + "ready" | "complete" => ready.push(item), + "downloading" | "in_progress" => downloading.push(item), + "failed" | "error" => failed.push(item), + _ => queued.push(item), + } + } + serde_json::to_string(&json!({ + "ready": ready, + "downloading": downloading, + "queued": queued, + "failed": failed + })) + .ok() +} + +pub(crate) fn playback_progress_merge_plan_json(request_json: &str) -> Option { + let request = serde_json::from_str::(request_json).ok()?; + let existing = &request.existing; + let incoming = &request.incoming; + + let existing_video_id = existing.get("lastVideoId").and_then(Value::as_str); + let incoming_video_id = incoming.get("lastVideoId").and_then(Value::as_str); + let video_changed = + incoming_video_id.is_some() && incoming_video_id != existing_video_id; + + let resolve_field = |key: &str| -> Value { + incoming + .get(key) + .filter(|v| !v.is_null()) + .cloned() + .or_else(|| existing.get(key).cloned()) + .unwrap_or(Value::Null) + }; + + let last_episode_name = if video_changed { + incoming.get("lastEpisodeName").cloned().unwrap_or(Value::Null) + } else { + resolve_field("lastEpisodeName") + }; + let resolve_episode_field = |key: &str| -> Value { + if video_changed { + incoming.get(key).cloned().unwrap_or(Value::Null) + } else { + resolve_field(key) + } + }; + + serde_json::to_string(&json!({ + "lastVideoId": resolve_field("lastVideoId"), + "timeOffset": incoming.get("timeOffset").cloned().unwrap_or(Value::Null), + "duration": incoming.get("duration").cloned().unwrap_or(Value::Null), + "lastStreamIndex": resolve_field("lastStreamIndex"), + "lastEpisodeName": last_episode_name, + "lastEpisodeSeason": resolve_episode_field("lastEpisodeSeason"), + "lastEpisodeNumber": resolve_episode_field("lastEpisodeNumber"), + "lastEpisodeThumbnail": resolve_episode_field("lastEpisodeThumbnail"), + "lastStreamUrl": resolve_field("lastStreamUrl"), + "lastStreamTitle": resolve_field("lastStreamTitle"), + "continueWatchingPoster": resolve_field("continueWatchingPoster"), + "continueWatchingBackground": resolve_field("continueWatchingBackground"), + "lastAudioLanguage": resolve_field("lastAudioLanguage"), + "lastSubtitleLanguage": resolve_field("lastSubtitleLanguage"), + "videoChanged": video_changed + })) + .ok() +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::Value; + + #[test] + fn toggle_plan_adds_when_not_in_watchlist() { + let result: Value = serde_json::from_str( + &watchlist_toggle_plan_json( + r#"{"item":{"id":"tt1","type":"movie"},"isCurrentlyInWatchlist":false}"#, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(result["command"], "add"); + assert_eq!(result["optimisticIsInWatchlist"], true); + } + + #[test] + fn toggle_plan_removes_when_in_watchlist() { + let result: Value = serde_json::from_str( + &watchlist_toggle_plan_json( + r#"{"item":{"id":"tt1","type":"movie"},"isCurrentlyInWatchlist":true}"#, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(result["command"], "remove"); + assert_eq!(result["optimisticIsInWatchlist"], false); + } + + #[test] + fn external_merge_deduplicates_preferring_local() { + let result: Value = serde_json::from_str( + &library_external_merge_plan_json( + r#"{"localItems":[{"id":"tt1","source":"local"}],"externalItems":[{"id":"tt1","source":"external"},{"id":"tt2","source":"external"}]}"#, + ) + .unwrap(), + ) + .unwrap(); + let merged = result["merged"].as_array().unwrap(); + assert_eq!(merged.len(), 2); + assert_eq!(merged[0]["source"], "local"); + assert_eq!(merged[1]["source"], "external"); + assert_eq!(merged[1]["id"], "tt2"); + } + + #[test] + fn collection_import_validation_rejects_missing_id() { + let result: Value = serde_json::from_str( + &library_collection_import_validation_json( + r#"{"collections":[{"title":"My List"},{"id":"c1","title":"Valid"}]}"#, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(result["isValid"], false); + assert_eq!(result["validCollections"].as_array().unwrap().len(), 1); + } + + #[test] + fn offline_grouping_partitions_by_status() { + let result: Value = serde_json::from_str( + &library_offline_grouping_json( + r#"{"items":[{"id":"a","status":"ready"},{"id":"b","status":"downloading"},{"id":"c","status":"failed"},{"id":"d"}]}"#, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(result["ready"].as_array().unwrap().len(), 1); + assert_eq!(result["downloading"].as_array().unwrap().len(), 1); + assert_eq!(result["failed"].as_array().unwrap().len(), 1); + assert_eq!(result["queued"].as_array().unwrap().len(), 1); + } + + #[test] + fn progress_merge_preserves_existing_fields_when_incoming_is_null() { + let result: Value = serde_json::from_str( + &playback_progress_merge_plan_json( + r#"{"existing":{"lastStreamUrl":"http://old","lastVideoId":"v1","timeOffset":1000},"incoming":{"lastVideoId":"v1","timeOffset":2000,"duration":5000}}"#, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(result["timeOffset"], 2000); + assert_eq!(result["lastStreamUrl"], "http://old"); + assert_eq!(result["videoChanged"], false); + } +} diff --git a/uniffi-bindgen.rs b/uniffi-bindgen.rs new file mode 100644 index 0000000..f6cff6c --- /dev/null +++ b/uniffi-bindgen.rs @@ -0,0 +1,3 @@ +fn main() { + uniffi::uniffi_bindgen_main() +} diff --git a/uniffi.toml b/uniffi.toml new file mode 100644 index 0000000..132a956 --- /dev/null +++ b/uniffi.toml @@ -0,0 +1,5 @@ +[bindings.kotlin] +package_name = "com.fluxa.core.uniffi" +android = true +cdylib_name = "fluxa_core" +kotlin_target_version = "2.3.0"